summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/frontend/src/app
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts392
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app.component.html1
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app.component.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app.module.ts51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts205
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.html57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.spec.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts207
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts346
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.html132
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.spec.ts133
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.ts123
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html692
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts593
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts822
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html92
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts98
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts87
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts71
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts60
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html53
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts309
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts242
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html49
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.spec.ts83
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.ts117
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html87
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts113
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts153
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html96
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts131
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts187
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.html13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.html63
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.spec.ts36
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.ts99
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html66
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.spec.ts79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts122
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.spec.ts86
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.ts111
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-response.model.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html100
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.spec.ts148
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.ts141
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-response.model.ts7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.html23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.spec.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.ts174
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts294
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts166
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts99
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html180
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-feature.interface.ts9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-mode.enum.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html395
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts480
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts815
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-parent.model.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html128
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts438
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts629
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.html79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.spec.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.ts144
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.html18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.spec.ts41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.ts157
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.html6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.spec.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.html41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.spec.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.ts137
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts131
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts305
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts336
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.html23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.ts19
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html52
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts172
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts225
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts94
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts94
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html46
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts105
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts81
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.html12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.scss8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.spec.ts81
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.ts196
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.html13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.spec.ts83
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.ts102
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.html42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.spec.ts55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.ts91
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts1111
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts733
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts61
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts215
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.ts130
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts123
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.html105
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.scss0
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.spec.ts26
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html160
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.spec.ts106
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts172
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.html26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.scss16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.spec.ts46
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.ts149
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.html54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.scss5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.spec.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts72
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html98
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts154
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts231
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.html39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.spec.ts137
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.ts122
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html107
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts168
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts171
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html77
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts419
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts535
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json324
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts194
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts254
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-host.model.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts67
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts90
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.html159
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.scss54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.spec.ts169
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.ts157
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html110
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts80
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts135
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts155
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts198
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html61
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.spec.ts105
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html67
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.ts44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts125
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts135
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts109
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts92
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html48
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts353
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts134
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.spec.ts99
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.ts156
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts97
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html213
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts309
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts285
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json605
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html150
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts641
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts624
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.html45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.spec.ts64
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.ts68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.options.ts36
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html92
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.scss0
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.spec.ts317
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.ts238
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts56
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.spec.ts50
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.ts52
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.html41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.spec.ts103
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts101
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list-helper.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.spec.ts40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.ts44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html224
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts598
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts340
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts140
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts191
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html85
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts209
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts107
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.spec.ts78
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.ts41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html102
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.scss14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts253
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts347
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html696
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts550
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts678
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts97
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts259
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.html322
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.spec.ts188
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.ts244
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.html16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.scss22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.spec.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.ts198
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html237
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.scss41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts348
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts278
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card-popover.scss42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.html18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.scss40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.spec.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.html26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.scss8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts72
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts78
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts52
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts48
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts193
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts91
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/models/nfs.fsal.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts102
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.html109
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.spec.ts71
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.ts95
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html400
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.scss11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts238
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts535
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts195
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts199
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter.module.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.html4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.spec.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts72
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html123
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts210
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts108
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html420
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.spec.ts688
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts459
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts171
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts80
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html609
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts1466
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts919
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.scss19
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts518
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts332
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-stat.ts16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts73
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capabilities.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html127
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.spec.ts42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html291
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts300
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts264
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts178
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts188
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts46
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html46
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts106
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts82
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html70
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts92
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html178
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts94
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts120
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html668
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts339
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts756
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.spec.ts166
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts180
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html125
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html128
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts71
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts130
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts108
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts71
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts56
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts63
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_ata_response.json570
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_nvme_response.json134
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_scsi_response.json208
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.html110
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.spec.ts264
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.ts212
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts87
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html95
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss68
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts77
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html64
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts76
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts67
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html121
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts222
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts315
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html21
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts83
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts169
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html263
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts258
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts305
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts82
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts164
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html121
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts83
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts119
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts100
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts70
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html63
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts49
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts96
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html1
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss61
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts60
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts70
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts131
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts141
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html272
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss263
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts237
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts123
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts53
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts63
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts98
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts76
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts99
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts110
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts91
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts154
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts97
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts60
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts66
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts108
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts46
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts183
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts190
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts123
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts247
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts82
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts164
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts114
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts181
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts198
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts102
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts128
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts90
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts82
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts170
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts179
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts49
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts154
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts77
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts104
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts66
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts220
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts221
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts70
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts132
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html77
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts295
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts120
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts272
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts147
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts185
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts235
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts63
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html2
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts67
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html2
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts59
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html69
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss0
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts149
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts110
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html78
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss33
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts81
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts201
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts85
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html19
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts33
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html131
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts194
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts167
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts107
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html19
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts276
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts149
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts52
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts130
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts99
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts107
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html19
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts305
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts213
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts161
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts351
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts224
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html327
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss295
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts799
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts927
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts80
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts90
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts132
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts122
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts53
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts89
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts82
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts76
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts50
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts21
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts33
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts184
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts906
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts612
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts95
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts48
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts21
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts115
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts62
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts50
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts253
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts4
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.spec.ts21
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts14
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts57
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts21
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts56
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts24
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts56
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts25
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts125
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts44
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.spec.ts41
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts21
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts227
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts133
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts92
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts72
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts90
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts77
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts33
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts102
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts101
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts117
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/ngzone-scheduler.service.ts48
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts49
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts285
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts237
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts208
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts95
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts214
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts100
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts227
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts133
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts78
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts144
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts52
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts46
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts179
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts89
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts133
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts111
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts72
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts312
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts424
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts98
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts33
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts71
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts68
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts50
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts19
1040 files changed, 87835 insertions, 0 deletions
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
new file mode 100644
index 000000000..4a490728b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
@@ -0,0 +1,392 @@
+import { Injectable, NgModule } from '@angular/core';
+import { ActivatedRouteSnapshot, PreloadAllModules, RouterModule, Routes } from '@angular/router';
+
+import _ from 'lodash';
+
+import { CephfsListComponent } from './ceph/cephfs/cephfs-list/cephfs-list.component';
+import { ConfigurationFormComponent } from './ceph/cluster/configuration/configuration-form/configuration-form.component';
+import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component';
+import { CreateClusterComponent } from './ceph/cluster/create-cluster/create-cluster.component';
+import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component';
+import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component';
+import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
+import { InventoryComponent } from './ceph/cluster/inventory/inventory.component';
+import { LogsComponent } from './ceph/cluster/logs/logs.component';
+import { MgrModuleFormComponent } from './ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component';
+import { MgrModuleListComponent } from './ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component';
+import { MonitorComponent } from './ceph/cluster/monitor/monitor.component';
+import { OsdFormComponent } from './ceph/cluster/osd/osd-form/osd-form.component';
+import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component';
+import { ActiveAlertListComponent } from './ceph/cluster/prometheus/active-alert-list/active-alert-list.component';
+import { RulesListComponent } from './ceph/cluster/prometheus/rules-list/rules-list.component';
+import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component';
+import { SilenceListComponent } from './ceph/cluster/prometheus/silence-list/silence-list.component';
+import { ServiceFormComponent } from './ceph/cluster/services/service-form/service-form.component';
+import { ServicesComponent } from './ceph/cluster/services/services.component';
+import { TelemetryComponent } from './ceph/cluster/telemetry/telemetry.component';
+import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
+import { NfsFormComponent } from './ceph/nfs/nfs-form/nfs-form.component';
+import { NfsListComponent } from './ceph/nfs/nfs-list/nfs-list.component';
+import { PerformanceCounterComponent } from './ceph/performance-counter/performance-counter/performance-counter.component';
+import { LoginPasswordFormComponent } from './core/auth/login-password-form/login-password-form.component';
+import { LoginComponent } from './core/auth/login/login.component';
+import { UserPasswordFormComponent } from './core/auth/user-password-form/user-password-form.component';
+import { ErrorComponent } from './core/error/error.component';
+import { BlankLayoutComponent } from './core/layouts/blank-layout/blank-layout.component';
+import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.component';
+import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
+import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
+import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
+import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
+import { AuthGuardService } from './shared/services/auth-guard.service';
+import { ChangePasswordGuardService } from './shared/services/change-password-guard.service';
+import { FeatureTogglesGuardService } from './shared/services/feature-toggles-guard.service';
+import { ModuleStatusGuardService } from './shared/services/module-status-guard.service';
+import { NoSsoGuardService } from './shared/services/no-sso-guard.service';
+
+@Injectable()
+export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot) {
+ const result: IBreadcrumb[] = [];
+
+ const fromPath = route.queryParams.fromLink || null;
+ let fromText = '';
+ switch (fromPath) {
+ case '/monitor':
+ fromText = 'Monitors';
+ break;
+ case '/hosts':
+ fromText = 'Hosts';
+ break;
+ }
+ result.push({ text: 'Cluster', path: null });
+ result.push({ text: fromText, path: fromPath });
+ result.push({ text: 'Performance Counters', path: '' });
+
+ return result;
+ }
+}
+
+@Injectable()
+export class StartCaseBreadcrumbsResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot) {
+ const path = route.params.name;
+ const text = _.startCase(path);
+ return [{ text: `${text}/Edit`, path: path }];
+ }
+}
+
+const routes: Routes = [
+ // Dashboard
+ { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
+ { path: 'api-docs', component: ApiDocsComponent },
+ {
+ path: '',
+ component: WorkbenchLayoutComponent,
+ canActivate: [AuthGuardService, ChangePasswordGuardService],
+ canActivateChild: [AuthGuardService, ChangePasswordGuardService],
+ children: [
+ { path: 'dashboard', component: DashboardComponent },
+ { path: 'error', component: ErrorComponent },
+
+ // Cluster
+ {
+ path: 'expand-cluster',
+ component: CreateClusterComponent,
+ canActivate: [ModuleStatusGuardService],
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'orchestrator',
+ redirectTo: 'dashboard',
+ backend: 'cephadm'
+ },
+ breadcrumbs: 'Expand Cluster'
+ }
+ },
+ {
+ path: 'hosts',
+ component: HostsComponent,
+ data: { breadcrumbs: 'Cluster/Hosts' },
+ children: [
+ {
+ path: URLVerbs.ADD,
+ component: HostFormComponent,
+ outlet: 'modal'
+ }
+ ]
+ },
+ {
+ path: 'monitor',
+ component: MonitorComponent,
+ data: { breadcrumbs: 'Cluster/Monitors' }
+ },
+ {
+ path: 'services',
+ component: ServicesComponent,
+ canActivate: [ModuleStatusGuardService],
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'orchestrator',
+ redirectTo: 'error',
+ section: 'orch',
+ section_info: 'Orchestrator',
+ header: 'Orchestrator is not available'
+ },
+ breadcrumbs: 'Cluster/Services'
+ },
+ children: [
+ {
+ path: URLVerbs.CREATE,
+ component: ServiceFormComponent,
+ outlet: 'modal'
+ },
+ {
+ path: `${URLVerbs.EDIT}/:type/:name`,
+ component: ServiceFormComponent,
+ outlet: 'modal'
+ }
+ ]
+ },
+ {
+ path: 'inventory',
+ canActivate: [ModuleStatusGuardService],
+ component: InventoryComponent,
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'orchestrator',
+ redirectTo: 'error',
+ section: 'orch',
+ section_info: 'Orchestrator',
+ header: 'Orchestrator is not available'
+ },
+ breadcrumbs: 'Cluster/Physical Disks'
+ }
+ },
+ {
+ path: 'osd',
+ data: { breadcrumbs: 'Cluster/OSDs' },
+ children: [
+ { path: '', component: OsdListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: OsdFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ }
+ ]
+ },
+ {
+ path: 'configuration',
+ data: { breadcrumbs: 'Cluster/Configuration' },
+ children: [
+ { path: '', component: ConfigurationComponent },
+ {
+ path: 'edit/:name',
+ component: ConfigurationFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ },
+ {
+ path: 'crush-map',
+ component: CrushmapComponent,
+ data: { breadcrumbs: 'Cluster/CRUSH map' }
+ },
+ {
+ path: 'logs',
+ component: LogsComponent,
+ data: { breadcrumbs: 'Cluster/Logs' }
+ },
+ {
+ path: 'telemetry',
+ component: TelemetryComponent,
+ data: { breadcrumbs: 'Telemetry configuration' }
+ },
+ {
+ path: 'monitoring',
+ data: { breadcrumbs: 'Cluster/Monitoring' },
+ children: [
+ { path: '', redirectTo: 'active-alerts', pathMatch: 'full' },
+ {
+ path: 'active-alerts',
+ data: { breadcrumbs: 'Active Alerts' },
+ component: ActiveAlertListComponent
+ },
+ {
+ path: 'alerts',
+ data: { breadcrumbs: 'Alerts' },
+ component: RulesListComponent
+ },
+ {
+ path: 'silences',
+ data: { breadcrumbs: 'Silences' },
+ children: [
+ {
+ path: '',
+ component: SilenceListComponent
+ },
+ {
+ path: URLVerbs.CREATE,
+ component: SilenceFormComponent,
+ data: { breadcrumbs: `${ActionLabels.CREATE} Silence` }
+ },
+ {
+ path: `${URLVerbs.CREATE}/:id`,
+ component: SilenceFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:id`,
+ component: SilenceFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ },
+ {
+ path: `${URLVerbs.RECREATE}/:id`,
+ component: SilenceFormComponent,
+ data: { breadcrumbs: ActionLabels.RECREATE }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ path: 'perf_counters/:type/:id',
+ component: PerformanceCounterComponent,
+ data: {
+ breadcrumbs: PerformanceCounterBreadcrumbsResolver
+ }
+ },
+ // Mgr modules
+ {
+ path: 'mgr-modules',
+ data: { breadcrumbs: 'Cluster/Manager Modules' },
+ children: [
+ {
+ path: '',
+ component: MgrModuleListComponent
+ },
+ {
+ path: 'edit/:name',
+ component: MgrModuleFormComponent,
+ data: {
+ breadcrumbs: StartCaseBreadcrumbsResolver
+ }
+ }
+ ]
+ },
+ // Pools
+ {
+ path: 'pool',
+ data: { breadcrumbs: 'Pools' },
+ loadChildren: () => import('./ceph/pool/pool.module').then((m) => m.RoutedPoolModule)
+ },
+ // Block
+ {
+ path: 'block',
+ data: { breadcrumbs: true, text: 'Block', path: null },
+ loadChildren: () => import('./ceph/block/block.module').then((m) => m.RoutedBlockModule)
+ },
+ // File Systems
+ {
+ path: 'cephfs',
+ component: CephfsListComponent,
+ canActivate: [FeatureTogglesGuardService],
+ data: { breadcrumbs: 'File Systems' }
+ },
+ // Object Gateway
+ {
+ path: 'rgw',
+ canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService],
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'rgw',
+ redirectTo: 'error',
+ section: 'rgw',
+ section_info: 'Object Gateway',
+ header: 'The Object Gateway Service is not configured'
+ },
+ breadcrumbs: true,
+ text: 'Object Gateway',
+ path: null
+ },
+ loadChildren: () => import('./ceph/rgw/rgw.module').then((m) => m.RoutedRgwModule)
+ },
+ // User/Role Management
+ {
+ path: 'user-management',
+ data: { breadcrumbs: 'User management', path: null },
+ loadChildren: () => import('./core/auth/auth.module').then((m) => m.RoutedAuthModule)
+ },
+ // User Profile
+ {
+ path: 'user-profile',
+ data: { breadcrumbs: 'User profile', path: null },
+ children: [
+ {
+ path: URLVerbs.EDIT,
+ component: UserPasswordFormComponent,
+ canActivate: [NoSsoGuardService],
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ },
+ // NFS
+ {
+ path: 'nfs',
+ canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService],
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'nfs-ganesha',
+ redirectTo: 'error',
+ section: 'nfs-ganesha',
+ section_info: 'NFS GANESHA',
+ header: 'NFS-Ganesha is not configured'
+ },
+ breadcrumbs: 'NFS'
+ },
+ children: [
+ { path: '', component: NfsListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: NfsFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:cluster_id/:export_id`,
+ component: NfsFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ path: '',
+ component: LoginLayoutComponent,
+ children: [
+ { path: 'login', component: LoginComponent },
+ {
+ path: 'login-change-password',
+ component: LoginPasswordFormComponent,
+ canActivate: [NoSsoGuardService]
+ }
+ ]
+ },
+ {
+ path: '',
+ component: BlankLayoutComponent,
+ children: [{ path: '**', redirectTo: '/error' }]
+ }
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forRoot(routes, {
+ useHash: true,
+ preloadingStrategy: PreloadAllModules,
+ relativeLinkResolution: 'legacy'
+ })
+ ],
+ exports: [RouterModule],
+ providers: [StartCaseBreadcrumbsResolver, PerformanceCounterBreadcrumbsResolver]
+})
+export class AppRoutingModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.html b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html
new file mode 100644
index 000000000..0680b43f9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html
@@ -0,0 +1 @@
+<router-outlet></router-outlet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/app.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts
new file mode 100644
index 000000000..71643d37c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AppComponent } from './app.component';
+
+describe('AppComponent', () => {
+ let component: AppComponent;
+ let fixture: ComponentFixture<AppComponent>;
+
+ configureTestBed({
+ declarations: [AppComponent],
+ imports: [RouterTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AppComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts
new file mode 100644
index 000000000..5f483cc94
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts
@@ -0,0 +1,18 @@
+import { Component } from '@angular/core';
+
+import { NgbPopoverConfig, NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+ selector: 'cd-root',
+ templateUrl: './app.component.html',
+ styleUrls: ['./app.component.scss']
+})
+export class AppComponent {
+ constructor(popoverConfig: NgbPopoverConfig, tooltipConfig: NgbTooltipConfig) {
+ popoverConfig.autoClose = 'outside';
+ popoverConfig.container = 'body';
+ popoverConfig.placement = 'bottom';
+
+ tooltipConfig.container = 'body';
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts
new file mode 100644
index 000000000..970f3a112
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts
@@ -0,0 +1,51 @@
+import { APP_BASE_HREF } from '@angular/common';
+import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
+import { ErrorHandler, NgModule } from '@angular/core';
+import { BrowserModule } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { AppRoutingModule } from './app-routing.module';
+import { AppComponent } from './app.component';
+import { CephModule } from './ceph/ceph.module';
+import { CoreModule } from './core/core.module';
+import { ApiInterceptorService } from './shared/services/api-interceptor.service';
+import { JsErrorHandler } from './shared/services/js-error-handler.service';
+import { SharedModule } from './shared/shared.module';
+
+@NgModule({
+ declarations: [AppComponent],
+ imports: [
+ HttpClientModule,
+ BrowserModule,
+ BrowserAnimationsModule,
+ ToastrModule.forRoot({
+ positionClass: 'toast-top-right',
+ preventDuplicates: true,
+ enableHtml: true
+ }),
+ AppRoutingModule,
+ CoreModule,
+ SharedModule,
+ CephModule
+ ],
+ exports: [SharedModule],
+ providers: [
+ {
+ provide: ErrorHandler,
+ useClass: JsErrorHandler
+ },
+ {
+ provide: HTTP_INTERCEPTORS,
+ useClass: ApiInterceptorService,
+ multi: true
+ },
+ {
+ provide: APP_BASE_HREF,
+ useValue: '/' + (window.location.pathname.split('/', 1)[1] || '')
+ }
+ ],
+ bootstrap: [AppComponent]
+})
+export class AppModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
new file mode 100644
index 000000000..8a13f1c69
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
@@ -0,0 +1,205 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule, Routes } from '@angular/router';
+
+import { TreeModule } from '@circlon/angular-tree-component';
+import { NgbNavModule, NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants';
+import { FeatureTogglesGuardService } from '~/app/shared/services/feature-toggles-guard.service';
+import { ModuleStatusGuardService } from '~/app/shared/services/module-status-guard.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { IscsiSettingComponent } from './iscsi-setting/iscsi-setting.component';
+import { IscsiTabsComponent } from './iscsi-tabs/iscsi-tabs.component';
+import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target-details.component';
+import { IscsiTargetDiscoveryModalComponent } from './iscsi-target-discovery-modal/iscsi-target-discovery-modal.component';
+import { IscsiTargetFormComponent } from './iscsi-target-form/iscsi-target-form.component';
+import { IscsiTargetImageSettingsModalComponent } from './iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component';
+import { IscsiTargetIqnSettingsModalComponent } from './iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component';
+import { IscsiTargetListComponent } from './iscsi-target-list/iscsi-target-list.component';
+import { IscsiComponent } from './iscsi/iscsi.component';
+import { MirroringModule } from './mirroring/mirroring.module';
+import { OverviewComponent as RbdMirroringComponent } from './mirroring/overview/overview.component';
+import { PoolEditModeModalComponent } from './mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component';
+import { RbdConfigurationFormComponent } from './rbd-configuration-form/rbd-configuration-form.component';
+import { RbdConfigurationListComponent } from './rbd-configuration-list/rbd-configuration-list.component';
+import { RbdDetailsComponent } from './rbd-details/rbd-details.component';
+import { RbdFormComponent } from './rbd-form/rbd-form.component';
+import { RbdListComponent } from './rbd-list/rbd-list.component';
+import { RbdNamespaceFormModalComponent } from './rbd-namespace-form/rbd-namespace-form-modal.component';
+import { RbdNamespaceListComponent } from './rbd-namespace-list/rbd-namespace-list.component';
+import { RbdPerformanceComponent } from './rbd-performance/rbd-performance.component';
+import { RbdSnapshotFormModalComponent } from './rbd-snapshot-form/rbd-snapshot-form-modal.component';
+import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdTabsComponent } from './rbd-tabs/rbd-tabs.component';
+import { RbdTrashListComponent } from './rbd-trash-list/rbd-trash-list.component';
+import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
+import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
+import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ MirroringModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbNavModule,
+ NgbPopoverModule,
+ NgbTooltipModule,
+ NgxPipeFunctionModule,
+ SharedModule,
+ RouterModule,
+ TreeModule
+ ],
+ declarations: [
+ RbdListComponent,
+ IscsiComponent,
+ IscsiSettingComponent,
+ IscsiTabsComponent,
+ IscsiTargetListComponent,
+ RbdDetailsComponent,
+ RbdFormComponent,
+ RbdNamespaceFormModalComponent,
+ RbdNamespaceListComponent,
+ RbdSnapshotListComponent,
+ RbdSnapshotFormModalComponent,
+ RbdTrashListComponent,
+ RbdTrashMoveModalComponent,
+ RbdTrashRestoreModalComponent,
+ RbdTrashPurgeModalComponent,
+ IscsiTargetDetailsComponent,
+ IscsiTargetFormComponent,
+ IscsiTargetImageSettingsModalComponent,
+ IscsiTargetIqnSettingsModalComponent,
+ IscsiTargetDiscoveryModalComponent,
+ RbdConfigurationListComponent,
+ RbdConfigurationFormComponent,
+ RbdTabsComponent,
+ RbdPerformanceComponent
+ ],
+ exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
+})
+export class BlockModule {}
+
+/* The following breakdown is needed to allow importing block.module without
+ the routes (e.g.: this module is imported by pool.module for RBD QoS
+ components)
+*/
+const routes: Routes = [
+ { path: '', redirectTo: 'rbd', pathMatch: 'full' },
+ {
+ path: 'rbd',
+ canActivate: [FeatureTogglesGuardService, ModuleStatusGuardService],
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'block/rbd',
+ redirectTo: 'error',
+ header: 'No RBD pools available',
+ button_name: 'Create RBD pool',
+ button_route: '/pool/create'
+ },
+ breadcrumbs: 'Images'
+ },
+ children: [
+ { path: '', component: RbdListComponent },
+ {
+ path: 'namespaces',
+ component: RbdNamespaceListComponent,
+ data: { breadcrumbs: 'Namespaces' }
+ },
+ {
+ path: 'trash',
+ component: RbdTrashListComponent,
+ data: { breadcrumbs: 'Trash' }
+ },
+ {
+ path: 'performance',
+ component: RbdPerformanceComponent,
+ data: { breadcrumbs: 'Overall Performance' }
+ },
+ {
+ path: URLVerbs.CREATE,
+ component: RbdFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:image_spec`,
+ component: RbdFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ },
+ {
+ path: `${URLVerbs.CLONE}/:image_spec/:snap`,
+ component: RbdFormComponent,
+ data: { breadcrumbs: ActionLabels.CLONE }
+ },
+ {
+ path: `${URLVerbs.COPY}/:image_spec`,
+ component: RbdFormComponent,
+ data: { breadcrumbs: ActionLabels.COPY }
+ },
+ {
+ path: `${URLVerbs.COPY}/:image_spec/:snap`,
+ component: RbdFormComponent,
+ data: { breadcrumbs: ActionLabels.COPY }
+ }
+ ]
+ },
+ {
+ path: 'mirroring',
+ component: RbdMirroringComponent,
+ canActivate: [FeatureTogglesGuardService, ModuleStatusGuardService],
+ data: {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'block/mirroring',
+ redirectTo: 'error',
+ header: $localize`RBD mirroring is not configured`,
+ button_name: $localize`Configure RBD Mirroring`,
+ button_title: $localize`This will create rbd-mirror service and a replicated RBD pool`,
+ component: 'RBD Mirroring',
+ uiConfig: true
+ },
+ breadcrumbs: 'Mirroring'
+ },
+ children: [
+ {
+ path: `${URLVerbs.EDIT}/:pool_name`,
+ component: PoolEditModeModalComponent,
+ outlet: 'modal'
+ }
+ ]
+ },
+ // iSCSI
+ {
+ path: 'iscsi',
+ canActivate: [FeatureTogglesGuardService],
+ data: { breadcrumbs: 'iSCSI' },
+ children: [
+ { path: '', redirectTo: 'overview', pathMatch: 'full' },
+ { path: 'overview', component: IscsiComponent, data: { breadcrumbs: 'Overview' } },
+ {
+ path: 'targets',
+ data: { breadcrumbs: 'Targets' },
+ children: [
+ { path: '', component: IscsiTargetListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: IscsiTargetFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:target_iqn`,
+ component: IscsiTargetFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ }
+ ]
+ }
+];
+
+@NgModule({
+ imports: [BlockModule, RouterModule.forChild(routes)]
+})
+export class RoutedBlockModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.html
new file mode 100644
index 000000000..b19941ae0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.html
@@ -0,0 +1,57 @@
+<div class="form-group"
+ [formGroup]="settingsForm">
+ <label class="col-form-label"
+ for="{{ setting }}">{{ setting }}</label>
+ <select id="{{ setting }}"
+ name="{{ setting }}"
+ *ngIf="limits['type'] === 'enum'"
+ class="form-control"
+ [formControlName]="setting">
+ <option [ngValue]="null"></option>
+ <option *ngFor="let opt of limits['values']"
+ [ngValue]="opt">{{ opt }}</option>
+ </select>
+
+ <span *ngIf="limits['type'] !== 'enum'">
+ <input type="number"
+ *ngIf="limits['type'] === 'int'"
+ class="form-control"
+ [formControlName]="setting">
+
+ <input type="text"
+ *ngIf="limits['type'] === 'str'"
+ class="form-control"
+ [formControlName]="setting">
+
+ <ng-container *ngIf="limits['type'] === 'bool'">
+ <br>
+ <div class="custom-control custom-radio custom-control-inline">
+ <input type="radio"
+ [id]="setting + 'True'"
+ [value]="true"
+ [formControlName]="setting"
+ class="custom-control-input">
+ <label class="custom-control-label"
+ [for]="setting + 'True'">Yes</label>
+ </div>
+ <div class="custom-control custom-radio custom-control-inline">
+ <input type="radio"
+ [id]="setting + 'False'"
+ [value]="false"
+ class="custom-control-input"
+ [formControlName]="setting">
+ <label class="custom-control-label"
+ [for]="setting + 'False'">No</label>
+ </div>
+ </ng-container>
+ </span>
+
+ <span class="invalid-feedback"
+ *ngIf="settingsForm.showError(setting, formDir, 'min')">
+ <ng-container i18n>Must be greater than or equal to {{ limits['min'] }}.</ng-container>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="settingsForm.showError(setting, formDir, 'max')">
+ <ng-container i18n>Must be less than or equal to {{ limits['max'] }}.</ng-container>
+ </span>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.spec.ts
new file mode 100644
index 000000000..19aee4df3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.spec.ts
@@ -0,0 +1,37 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, NgForm, ReactiveFormsModule } from '@angular/forms';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiSettingComponent } from './iscsi-setting.component';
+
+describe('IscsiSettingComponent', () => {
+ let component: IscsiSettingComponent;
+ let fixture: ComponentFixture<IscsiSettingComponent>;
+
+ configureTestBed({
+ imports: [SharedModule, ReactiveFormsModule],
+ declarations: [IscsiSettingComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiSettingComponent);
+ component = fixture.componentInstance;
+ component.settingsForm = new CdFormGroup({
+ max_data_area_mb: new FormControl()
+ });
+ component.formDir = new NgForm([], []);
+ component.setting = 'max_data_area_mb';
+ component.limits = {
+ type: 'int',
+ min: 1,
+ max: 2048
+ };
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.ts
new file mode 100644
index 000000000..fbbd28b20
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-setting/iscsi-setting.component.ts
@@ -0,0 +1,31 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { NgForm, ValidatorFn, Validators } from '@angular/forms';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+@Component({
+ selector: 'cd-iscsi-setting',
+ templateUrl: './iscsi-setting.component.html',
+ styleUrls: ['./iscsi-setting.component.scss']
+})
+export class IscsiSettingComponent implements OnInit {
+ @Input()
+ settingsForm: CdFormGroup;
+ @Input()
+ formDir: NgForm;
+ @Input()
+ setting: string;
+ @Input()
+ limits: object;
+
+ ngOnInit() {
+ const validators: ValidatorFn[] = [];
+ if ('min' in this.limits) {
+ validators.push(Validators.min(this.limits['min']));
+ }
+ if ('max' in this.limits) {
+ validators.push(Validators.max(this.limits['max']));
+ }
+ this.settingsForm.get(this.setting).setValidators(validators);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html
new file mode 100644
index 000000000..3d328cb6b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html
@@ -0,0 +1,14 @@
+<ul ngbNav
+ #nav="ngbNav"
+ [activeId]="router.url"
+ (navChange)="router.navigate([$event.nextId])"
+ class="nav-tabs">
+ <li ngbNavItem="/block/iscsi/overview">
+ <a ngbNavLink
+ i18n>Overview</a>
+ </li>
+ <li ngbNavItem="/block/iscsi/targets">
+ <a ngbNavLink
+ i18n>Targets</a>
+ </li>
+</ul>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts
new file mode 100644
index 000000000..9bdddf78d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiTabsComponent } from './iscsi-tabs.component';
+
+describe('IscsiTabsComponent', () => {
+ let component: IscsiTabsComponent;
+ let fixture: ComponentFixture<IscsiTabsComponent>;
+
+ configureTestBed({
+ imports: [SharedModule, RouterTestingModule, NgbNavModule],
+ declarations: [IscsiTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTabsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts
new file mode 100644
index 000000000..d4d21361b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts
@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'cd-iscsi-tabs',
+ templateUrl: './iscsi-tabs.component.html',
+ styleUrls: ['./iscsi-tabs.component.scss']
+})
+export class IscsiTabsComponent {
+ constructor(public router: Router) {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html
new file mode 100644
index 000000000..29d91ef47
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html
@@ -0,0 +1,41 @@
+<div class="row">
+ <div class="col-6">
+ <legend i18n>iSCSI Topology</legend>
+
+ <tree-root #tree
+ [nodes]="nodes"
+ [options]="treeOptions"
+ (updateData)="onUpdateData()">
+ <ng-template #treeNodeTemplate
+ let-node
+ let-index="index">
+ <i [class]="node.data.cdIcon"></i>
+ <span>{{ node.data.name }}</span>
+ &nbsp;
+ <span class="badge"
+ [ngClass]="{'badge-success': ['logged_in'].includes(node.data.status), 'badge-danger': ['logged_out'].includes(node.data.status)}">
+ {{ node.data.status }}
+ </span>
+ </ng-template>
+ </tree-root>
+ </div>
+
+ <div class="col-6 metadata"
+ *ngIf="data">
+ <legend>{{ title }}</legend>
+
+ <cd-table #detailTable
+ [data]="data"
+ columnMode="flex"
+ [columns]="columns"
+ [limit]="0">
+ </cd-table>
+ </div>
+</div>
+
+<ng-template #highlightTpl
+ let-row="row"
+ let-value="value">
+ <span *ngIf="row.default === undefined || row.default === row.current">{{ value }}</span>
+ <strong *ngIf="row.default !== undefined && row.default !== row.current">{{ value }}</strong>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts
new file mode 100644
index 000000000..d95ed76e5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts
@@ -0,0 +1,207 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { TreeModel, TreeModule } from '@circlon/angular-tree-component';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiTargetDetailsComponent } from './iscsi-target-details.component';
+
+describe('IscsiTargetDetailsComponent', () => {
+ let component: IscsiTargetDetailsComponent;
+ let fixture: ComponentFixture<IscsiTargetDetailsComponent>;
+
+ configureTestBed({
+ declarations: [IscsiTargetDetailsComponent],
+ imports: [BrowserAnimationsModule, TreeModule, SharedModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTargetDetailsComponent);
+ component = fixture.componentInstance;
+
+ component.settings = {
+ config: { minimum_gateways: 2 },
+ disk_default_controls: {
+ 'backstore:1': {
+ hw_max_sectors: 1024,
+ max_data_area_mb: 8
+ },
+ 'backstore:2': {
+ hw_max_sectors: 1024,
+ max_data_area_mb: 8
+ }
+ },
+ target_default_controls: {
+ cmdsn_depth: 128,
+ dataout_timeout: 20
+ },
+ backstores: ['backstore:1', 'backstore:2'],
+ default_backstore: 'backstore:1'
+ };
+ component.selection = undefined;
+ component.selection = {
+ target_iqn: 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw',
+ portals: [{ host: 'node1', ip: '192.168.100.201' }],
+ disks: [
+ {
+ pool: 'rbd',
+ image: 'disk_1',
+ backstore: 'backstore:1',
+ controls: { hw_max_sectors: 1 }
+ }
+ ],
+ clients: [
+ {
+ client_iqn: 'iqn.1994-05.com.redhat:rh7-client',
+ luns: [{ pool: 'rbd', image: 'disk_1' }],
+ auth: {
+ user: 'myiscsiusername'
+ },
+ info: {
+ alias: 'myhost',
+ ip_address: ['192.168.200.1'],
+ state: { LOGGED_IN: ['node1'] }
+ }
+ }
+ ],
+ groups: [],
+ target_controls: { dataout_timeout: 2 }
+ };
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should empty data and generateTree when ngOnChanges is called', () => {
+ const tempData = [{ current: 'baz', default: 'bar', displayName: 'foo' }];
+ component.data = tempData;
+ fixture.detectChanges();
+
+ expect(component.data).toEqual(tempData);
+ expect(component.metadata).toEqual({});
+ expect(component.nodes).toEqual([]);
+
+ component.ngOnChanges();
+
+ expect(component.data).toBeUndefined();
+ expect(component.metadata).toEqual({
+ 'client_iqn.1994-05.com.redhat:rh7-client': {
+ user: 'myiscsiusername',
+ alias: 'myhost',
+ ip_address: ['192.168.200.1'],
+ logged_in: ['node1']
+ },
+ disk_rbd_disk_1: { backstore: 'backstore:1', controls: { hw_max_sectors: 1 } },
+ root: { dataout_timeout: 2 }
+ });
+ expect(component.nodes).toEqual([
+ {
+ cdIcon: 'fa fa-lg fa fa-bullseye',
+ cdId: 'root',
+ children: [
+ {
+ cdIcon: 'fa fa-lg fa fa-hdd-o',
+ children: [
+ {
+ cdIcon: 'fa fa-hdd-o',
+ cdId: 'disk_rbd_disk_1',
+ name: 'rbd/disk_1'
+ }
+ ],
+ isExpanded: true,
+ name: 'Disks'
+ },
+ {
+ cdIcon: 'fa fa-lg fa fa-server',
+ children: [
+ {
+ cdIcon: 'fa fa-server',
+ name: 'node1:192.168.100.201'
+ }
+ ],
+ isExpanded: true,
+ name: 'Portals'
+ },
+ {
+ cdIcon: 'fa fa-lg fa fa-user',
+ children: [
+ {
+ cdIcon: 'fa fa-user',
+ cdId: 'client_iqn.1994-05.com.redhat:rh7-client',
+ children: [
+ {
+ cdIcon: 'fa fa-hdd-o',
+ cdId: 'disk_rbd_disk_1',
+ name: 'rbd/disk_1'
+ }
+ ],
+ name: 'iqn.1994-05.com.redhat:rh7-client',
+ status: 'logged_in'
+ }
+ ],
+ isExpanded: true,
+ name: 'Initiators'
+ },
+ {
+ cdIcon: 'fa fa-lg fa fa-users',
+ children: [],
+ isExpanded: true,
+ name: 'Groups'
+ }
+ ],
+ isExpanded: true,
+ name: 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw'
+ }
+ ]);
+ });
+
+ describe('should update data when onNodeSelected is called', () => {
+ let tree: TreeModel;
+
+ beforeEach(() => {
+ component.ngOnChanges();
+ tree = component.tree.treeModel;
+ fixture.detectChanges();
+ });
+
+ it('with target selected', () => {
+ const node = tree.getNodeBy({ data: { cdId: 'root' } });
+ component.onNodeSelected(tree, node);
+ expect(component.data).toEqual([
+ { current: 128, default: 128, displayName: 'cmdsn_depth' },
+ { current: 2, default: 20, displayName: 'dataout_timeout' }
+ ]);
+ });
+
+ it('with disk selected', () => {
+ const node = tree.getNodeBy({ data: { cdId: 'disk_rbd_disk_1' } });
+ component.onNodeSelected(tree, node);
+ expect(component.data).toEqual([
+ { current: 1, default: 1024, displayName: 'hw_max_sectors' },
+ { current: 8, default: 8, displayName: 'max_data_area_mb' },
+ { current: 'backstore:1', default: 'backstore:1', displayName: 'backstore' }
+ ]);
+ });
+
+ it('with initiator selected', () => {
+ const node = tree.getNodeBy({ data: { cdId: 'client_iqn.1994-05.com.redhat:rh7-client' } });
+ component.onNodeSelected(tree, node);
+ expect(component.data).toEqual([
+ { current: 'myiscsiusername', default: undefined, displayName: 'user' },
+ { current: 'myhost', default: undefined, displayName: 'alias' },
+ { current: ['192.168.200.1'], default: undefined, displayName: 'ip_address' },
+ { current: ['node1'], default: undefined, displayName: 'logged_in' }
+ ]);
+ });
+
+ it('with any other selected', () => {
+ const node = tree.getNodeBy({ data: { name: 'Disks' } });
+ component.onNodeSelected(tree, node);
+ expect(component.data).toBeUndefined();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts
new file mode 100644
index 000000000..3840bb3fb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts
@@ -0,0 +1,346 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import {
+ ITreeOptions,
+ TreeComponent,
+ TreeModel,
+ TreeNode,
+ TREE_ACTIONS
+} from '@circlon/angular-tree-component';
+import _ from 'lodash';
+
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { BooleanTextPipe } from '~/app/shared/pipes/boolean-text.pipe';
+import { IscsiBackstorePipe } from '~/app/shared/pipes/iscsi-backstore.pipe';
+
+@Component({
+ selector: 'cd-iscsi-target-details',
+ templateUrl: './iscsi-target-details.component.html',
+ styleUrls: ['./iscsi-target-details.component.scss']
+})
+export class IscsiTargetDetailsComponent implements OnChanges, OnInit {
+ @Input()
+ selection: any;
+ @Input()
+ settings: any;
+ @Input()
+ cephIscsiConfigVersion: number;
+
+ @ViewChild('highlightTpl', { static: true })
+ highlightTpl: TemplateRef<any>;
+
+ private detailTable: TableComponent;
+ @ViewChild('detailTable')
+ set content(content: TableComponent) {
+ this.detailTable = content;
+ if (content) {
+ content.updateColumns();
+ }
+ }
+
+ @ViewChild('tree') tree: TreeComponent;
+
+ icons = Icons;
+ columns: CdTableColumn[];
+ data: any;
+ metadata: any = {};
+ selectedItem: any;
+ title: string;
+
+ nodes: any[] = [];
+ treeOptions: ITreeOptions = {
+ useVirtualScroll: true,
+ actionMapping: {
+ mouse: {
+ click: this.onNodeSelected.bind(this)
+ }
+ }
+ };
+
+ constructor(
+ private iscsiBackstorePipe: IscsiBackstorePipe,
+ private booleanTextPipe: BooleanTextPipe
+ ) {}
+
+ ngOnInit() {
+ this.columns = [
+ {
+ prop: 'displayName',
+ name: $localize`Name`,
+ flexGrow: 1,
+ cellTemplate: this.highlightTpl
+ },
+ {
+ prop: 'current',
+ name: $localize`Current`,
+ flexGrow: 1,
+ cellTemplate: this.highlightTpl
+ },
+ {
+ prop: 'default',
+ name: $localize`Default`,
+ flexGrow: 1,
+ cellTemplate: this.highlightTpl
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.selectedItem = this.selection;
+ this.generateTree();
+ }
+
+ this.data = undefined;
+ }
+
+ private generateTree() {
+ const target_meta = _.cloneDeep(this.selectedItem.target_controls);
+ // Target level authentication was introduced in ceph-iscsi config v11
+ if (this.cephIscsiConfigVersion > 10) {
+ _.extend(target_meta, _.cloneDeep(this.selectedItem.auth));
+ }
+ this.metadata = { root: target_meta };
+ const cssClasses = {
+ target: {
+ expanded: _.join(
+ this.selectedItem.cdExecuting
+ ? [Icons.large, Icons.spinner, Icons.spin]
+ : [Icons.large, Icons.bullseye],
+ ' '
+ )
+ },
+ initiators: {
+ expanded: _.join([Icons.large, Icons.user], ' '),
+ leaf: _.join([Icons.user], ' ')
+ },
+ groups: {
+ expanded: _.join([Icons.large, Icons.users], ' '),
+ leaf: _.join([Icons.users], ' ')
+ },
+ disks: {
+ expanded: _.join([Icons.large, Icons.disk], ' '),
+ leaf: _.join([Icons.disk], ' ')
+ },
+ portals: {
+ expanded: _.join([Icons.large, Icons.server], ' '),
+ leaf: _.join([Icons.server], ' ')
+ }
+ };
+
+ const disks: any[] = [];
+ _.forEach(this.selectedItem.disks, (disk) => {
+ const cdId = 'disk_' + disk.pool + '_' + disk.image;
+ this.metadata[cdId] = {
+ controls: disk.controls,
+ backstore: disk.backstore
+ };
+ ['wwn', 'lun'].forEach((k) => {
+ if (k in disk) {
+ this.metadata[cdId][k] = disk[k];
+ }
+ });
+ disks.push({
+ name: `${disk.pool}/${disk.image}`,
+ cdId: cdId,
+ cdIcon: cssClasses.disks.leaf
+ });
+ });
+
+ const portals: any[] = [];
+ _.forEach(this.selectedItem.portals, (portal) => {
+ portals.push({
+ name: `${portal.host}:${portal.ip}`,
+ cdIcon: cssClasses.portals.leaf
+ });
+ });
+
+ const clients: any[] = [];
+ _.forEach(this.selectedItem.clients, (client) => {
+ const client_metadata = _.cloneDeep(client.auth);
+ if (client.info) {
+ _.extend(client_metadata, client.info);
+ delete client_metadata['state'];
+ _.forEach(Object.keys(client.info.state), (state) => {
+ client_metadata[state.toLowerCase()] = client.info.state[state];
+ });
+ }
+ this.metadata['client_' + client.client_iqn] = client_metadata;
+
+ const luns: any[] = [];
+ client.luns.forEach((lun: Record<string, any>) => {
+ luns.push({
+ name: `${lun.pool}/${lun.image}`,
+ cdId: 'disk_' + lun.pool + '_' + lun.image,
+ cdIcon: cssClasses.disks.leaf
+ });
+ });
+
+ let status = '';
+ if (client.info) {
+ status = Object.keys(client.info.state).includes('LOGGED_IN') ? 'logged_in' : 'logged_out';
+ }
+ clients.push({
+ name: client.client_iqn,
+ status: status,
+ cdId: 'client_' + client.client_iqn,
+ children: luns,
+ cdIcon: cssClasses.initiators.leaf
+ });
+ });
+
+ const groups: any[] = [];
+ _.forEach(this.selectedItem.groups, (group) => {
+ const luns: any[] = [];
+ group.disks.forEach((disk: Record<string, any>) => {
+ luns.push({
+ name: `${disk.pool}/${disk.image}`,
+ cdId: 'disk_' + disk.pool + '_' + disk.image,
+ cdIcon: cssClasses.disks.leaf
+ });
+ });
+
+ const initiators: any[] = [];
+ group.members.forEach((member: string) => {
+ initiators.push({
+ name: member,
+ cdId: 'client_' + member
+ });
+ });
+
+ groups.push({
+ name: group.group_id,
+ cdIcon: cssClasses.groups.leaf,
+ children: [
+ {
+ name: 'Disks',
+ children: luns,
+ cdIcon: cssClasses.disks.expanded
+ },
+ {
+ name: 'Initiators',
+ children: initiators,
+ cdIcon: cssClasses.initiators.expanded
+ }
+ ]
+ });
+ });
+
+ this.nodes = [
+ {
+ name: this.selectedItem.target_iqn,
+ cdId: 'root',
+ isExpanded: true,
+ cdIcon: cssClasses.target.expanded,
+ children: [
+ {
+ name: 'Disks',
+ isExpanded: true,
+ children: disks,
+ cdIcon: cssClasses.disks.expanded
+ },
+ {
+ name: 'Portals',
+ isExpanded: true,
+ children: portals,
+ cdIcon: cssClasses.portals.expanded
+ },
+ {
+ name: 'Initiators',
+ isExpanded: true,
+ children: clients,
+ cdIcon: cssClasses.initiators.expanded
+ },
+ {
+ name: 'Groups',
+ isExpanded: true,
+ children: groups,
+ cdIcon: cssClasses.groups.expanded
+ }
+ ]
+ }
+ ];
+ }
+
+ private format(value: any) {
+ if (typeof value === 'boolean') {
+ return this.booleanTextPipe.transform(value);
+ }
+ return value;
+ }
+
+ onNodeSelected(tree: TreeModel, node: TreeNode) {
+ TREE_ACTIONS.ACTIVATE(tree, node, true);
+ if (node.data.cdId) {
+ this.title = node.data.name;
+ const tempData = this.metadata[node.data.cdId] || {};
+
+ if (node.data.cdId === 'root') {
+ this.detailTable?.toggleColumn({ prop: 'default', isHidden: true });
+ this.data = _.map(this.settings.target_default_controls, (value, key) => {
+ value = this.format(value);
+ return {
+ displayName: key,
+ default: value,
+ current: !_.isUndefined(tempData[key]) ? this.format(tempData[key]) : value
+ };
+ });
+ // Target level authentication was introduced in ceph-iscsi config v11
+ if (this.cephIscsiConfigVersion > 10) {
+ ['user', 'password', 'mutual_user', 'mutual_password'].forEach((key) => {
+ this.data.push({
+ displayName: key,
+ default: null,
+ current: tempData[key]
+ });
+ });
+ }
+ } else if (node.data.cdId.toString().startsWith('disk_')) {
+ this.detailTable?.toggleColumn({ prop: 'default', isHidden: true });
+ this.data = _.map(this.settings.disk_default_controls[tempData.backstore], (value, key) => {
+ value = this.format(value);
+ return {
+ displayName: key,
+ default: value,
+ current: !_.isUndefined(tempData.controls[key])
+ ? this.format(tempData.controls[key])
+ : value
+ };
+ });
+ this.data.push({
+ displayName: 'backstore',
+ default: this.iscsiBackstorePipe.transform(this.settings.default_backstore),
+ current: this.iscsiBackstorePipe.transform(tempData.backstore)
+ });
+ ['wwn', 'lun'].forEach((k) => {
+ if (k in tempData) {
+ this.data.push({
+ displayName: k,
+ default: undefined,
+ current: tempData[k]
+ });
+ }
+ });
+ } else {
+ this.detailTable?.toggleColumn({ prop: 'default', isHidden: false });
+ this.data = _.map(tempData, (value, key) => {
+ return {
+ displayName: key,
+ default: undefined,
+ current: this.format(value)
+ };
+ });
+ }
+ } else {
+ this.data = undefined;
+ }
+
+ this.detailTable?.updateColumns();
+ }
+
+ onUpdateData() {
+ this.tree.treeModel.expandAll();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.html
new file mode 100644
index 000000000..d84ea787f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.html
@@ -0,0 +1,132 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>Discovery Authentication</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="discoveryForm"
+ #formDir="ngForm"
+ [formGroup]="discoveryForm"
+ novalidate>
+ <div class="modal-body">
+ <!-- User -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="user"
+ i18n>User</label>
+ <div class="cd-col-form-input">
+ <input id="user"
+ class="form-control"
+ formControlName="user"
+ type="text"
+ autocomplete="off">
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('user', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('user', formDir, 'pattern')"
+ i18n>User names must have a length of 8 to 64 characters and can contain
+ alphanumeric characters, '.', '@', '-', '_' or ':'.</span>
+ </div>
+ </div>
+
+ <!-- Password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="password"
+ i18n>Password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="password"
+ class="form-control"
+ formControlName="password"
+ type="password"
+ autocomplete="new-password">
+
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="password">
+ </button>
+ <cd-copy-2-clipboard-button source="password">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('password', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('password', formDir, 'pattern')"
+ i18n>Passwords must have a length of 12 to 16 characters and can contain
+ alphanumeric characters, '@', '-', '_' or '/'.</span>
+ </div>
+ </div>
+
+ <!-- mutual_user -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="mutual_user">
+ <ng-container i18n>Mutual User</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="mutual_user"
+ class="form-control"
+ formControlName="mutual_user"
+ type="text"
+ autocomplete="off">
+
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('mutual_user', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('mutual_user', formDir, 'pattern')"
+ i18n>User names must have a length of 8 to 64 characters and can contain
+ alphanumeric characters, '.', '@', '-', '_' or ':'.</span>
+ </div>
+ </div>
+
+ <!-- mutual_password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="mutual_password"
+ i18n>Mutual Password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="mutual_password"
+ class="form-control"
+ formControlName="mutual_password"
+ type="password"
+ autocomplete="new-password">
+
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="mutual_password">
+ </button>
+ <cd-copy-2-clipboard-button source="mutual_password">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('mutual_password', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="discoveryForm.showError('mutual_password', formDir, 'pattern')"
+ i18n>Passwords must have a length of 12 to 16 characters and can contain
+ alphanumeric characters, '@', '-', '_' or '/'.</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submitAction()"
+ [form]="discoveryForm"
+ [showSubmit]="hasPermission"
+ [submitText]="actionLabels.SUBMIT"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.spec.ts
new file mode 100644
index 000000000..0f540f18e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.spec.ts
@@ -0,0 +1,133 @@
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+ TestRequest
+} from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper, IscsiHelper } from '~/testing/unit-test-helper';
+import { IscsiTargetDiscoveryModalComponent } from './iscsi-target-discovery-modal.component';
+
+describe('IscsiTargetDiscoveryModalComponent', () => {
+ let component: IscsiTargetDiscoveryModalComponent;
+ let fixture: ComponentFixture<IscsiTargetDiscoveryModalComponent>;
+ let httpTesting: HttpTestingController;
+ let req: TestRequest;
+
+ const elem = (css: string) => fixture.debugElement.query(By.css(css));
+ const elemDisabled = (css: string) => elem(css).nativeElement.disabled;
+
+ configureTestBed({
+ declarations: [IscsiTargetDiscoveryModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule
+ ],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTargetDiscoveryModalComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ describe('with update permissions', () => {
+ beforeEach(() => {
+ component.permission = new Permission(['update']);
+ fixture.detectChanges();
+ req = httpTesting.expectOne('api/iscsi/discoveryauth');
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should create form', () => {
+ expect(component.discoveryForm.value).toEqual({
+ user: '',
+ password: '',
+ mutual_user: '',
+ mutual_password: ''
+ });
+ });
+
+ it('should patch form', () => {
+ req.flush({
+ user: 'foo',
+ password: 'bar',
+ mutual_user: 'mutual_foo',
+ mutual_password: 'mutual_bar'
+ });
+ expect(component.discoveryForm.value).toEqual({
+ user: 'foo',
+ password: 'bar',
+ mutual_user: 'mutual_foo',
+ mutual_password: 'mutual_bar'
+ });
+ });
+
+ it('should submit new values', () => {
+ component.discoveryForm.patchValue({
+ user: 'new_user',
+ password: 'new_pass',
+ mutual_user: 'mutual_new_user',
+ mutual_password: 'mutual_new_pass'
+ });
+ component.submitAction();
+
+ const submit_req = httpTesting.expectOne('api/iscsi/discoveryauth');
+ expect(submit_req.request.method).toBe('PUT');
+ expect(submit_req.request.body).toEqual({
+ user: 'new_user',
+ password: 'new_pass',
+ mutual_user: 'mutual_new_user',
+ mutual_password: 'mutual_new_pass'
+ });
+ });
+
+ it('should enable form if user has update permission', () => {
+ expect(elemDisabled('input#user')).toBeFalsy();
+ expect(elemDisabled('input#password')).toBeFalsy();
+ expect(elemDisabled('input#mutual_user')).toBeFalsy();
+ expect(elemDisabled('input#mutual_password')).toBeFalsy();
+ expect(elem('cd-submit-button')).toBeDefined();
+ });
+ });
+
+ it('should disabled form if user does not have update permission', () => {
+ component.permission = new Permission(['read', 'create', 'delete']);
+ fixture.detectChanges();
+ req = httpTesting.expectOne('api/iscsi/discoveryauth');
+
+ expect(elemDisabled('input#user')).toBeTruthy();
+ expect(elemDisabled('input#password')).toBeTruthy();
+ expect(elemDisabled('input#mutual_user')).toBeTruthy();
+ expect(elemDisabled('input#mutual_password')).toBeTruthy();
+ expect(elem('cd-submit-button')).toBeNull();
+ });
+
+ it('should validate authentication', () => {
+ component.permission = new Permission(['read', 'create', 'update', 'delete']);
+ fixture.detectChanges();
+ const control = component.discoveryForm;
+ const formHelper = new FormHelper(control);
+ formHelper.expectValid(control);
+
+ IscsiHelper.validateUser(formHelper, 'user');
+ IscsiHelper.validatePassword(formHelper, 'password');
+ IscsiHelper.validateUser(formHelper, 'mutual_user');
+ IscsiHelper.validatePassword(formHelper, 'mutual_password');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.ts
new file mode 100644
index 000000000..68958cfaa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-discovery-modal/iscsi-target-discovery-modal.component.ts
@@ -0,0 +1,123 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-iscsi-target-discovery-modal',
+ templateUrl: './iscsi-target-discovery-modal.component.html',
+ styleUrls: ['./iscsi-target-discovery-modal.component.scss']
+})
+export class IscsiTargetDiscoveryModalComponent implements OnInit {
+ discoveryForm: CdFormGroup;
+ permission: Permission;
+ hasPermission: boolean;
+
+ USER_REGEX = /^[\w\.:@_-]{8,64}$/;
+ PASSWORD_REGEX = /^[\w@\-_\/]{12,16}$/;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private iscsiService: IscsiService,
+ private notificationService: NotificationService
+ ) {
+ this.permission = this.authStorageService.getPermissions().iscsi;
+ }
+
+ ngOnInit() {
+ this.hasPermission = this.permission.update;
+ this.createForm();
+ this.iscsiService.getDiscovery().subscribe((auth) => {
+ this.discoveryForm.patchValue(auth);
+ });
+ }
+
+ createForm() {
+ this.discoveryForm = new CdFormGroup({
+ user: new FormControl({ value: '', disabled: !this.hasPermission }),
+ password: new FormControl({ value: '', disabled: !this.hasPermission }),
+ mutual_user: new FormControl({ value: '', disabled: !this.hasPermission }),
+ mutual_password: new FormControl({ value: '', disabled: !this.hasPermission })
+ });
+
+ CdValidators.validateIf(
+ this.discoveryForm.get('user'),
+ () =>
+ this.discoveryForm.getValue('password') ||
+ this.discoveryForm.getValue('mutual_user') ||
+ this.discoveryForm.getValue('mutual_password'),
+ [Validators.required],
+ [Validators.pattern(this.USER_REGEX)],
+ [
+ this.discoveryForm.get('password'),
+ this.discoveryForm.get('mutual_user'),
+ this.discoveryForm.get('mutual_password')
+ ]
+ );
+
+ CdValidators.validateIf(
+ this.discoveryForm.get('password'),
+ () =>
+ this.discoveryForm.getValue('user') ||
+ this.discoveryForm.getValue('mutual_user') ||
+ this.discoveryForm.getValue('mutual_password'),
+ [Validators.required],
+ [Validators.pattern(this.PASSWORD_REGEX)],
+ [
+ this.discoveryForm.get('user'),
+ this.discoveryForm.get('mutual_user'),
+ this.discoveryForm.get('mutual_password')
+ ]
+ );
+
+ CdValidators.validateIf(
+ this.discoveryForm.get('mutual_user'),
+ () => this.discoveryForm.getValue('mutual_password'),
+ [Validators.required],
+ [Validators.pattern(this.USER_REGEX)],
+ [
+ this.discoveryForm.get('user'),
+ this.discoveryForm.get('password'),
+ this.discoveryForm.get('mutual_password')
+ ]
+ );
+
+ CdValidators.validateIf(
+ this.discoveryForm.get('mutual_password'),
+ () => this.discoveryForm.getValue('mutual_user'),
+ [Validators.required],
+ [Validators.pattern(this.PASSWORD_REGEX)],
+ [
+ this.discoveryForm.get('user'),
+ this.discoveryForm.get('password'),
+ this.discoveryForm.get('mutual_user')
+ ]
+ );
+ }
+
+ submitAction() {
+ this.iscsiService.updateDiscovery(this.discoveryForm.value).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated discovery authentication`
+ );
+ this.activeModal.close();
+ },
+ () => {
+ this.discoveryForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html
new file mode 100644
index 000000000..852866a86
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html
@@ -0,0 +1,692 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="targetForm"
+ #formDir="ngForm"
+ [formGroup]="targetForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- Target IQN -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="target_iqn"
+ i18n>Target IQN</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="text"
+ id="target_iqn"
+ name="target_iqn"
+ formControlName="target_iqn"
+ cdTrim />
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ id="ecp-info-button"
+ type="button"
+ (click)="targetSettingsModal()">
+ <i [ngClass]="[icons.deepCheck]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('target_iqn', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('target_iqn', formDir, 'pattern')"
+ i18n>IQN has wrong pattern.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('target_iqn', formDir, 'iqn')">
+ <ng-container i18n>An IQN has the following notation
+ 'iqn.$year-$month.$reversedAddress:$definedName'</ng-container>
+ <br>
+ <ng-container i18n>For example: iqn.2016-06.org.dashboard:storage:disk.sn-a8675309</ng-container>
+ <br>
+ <a target="_blank"
+ href="https://en.wikipedia.org/wiki/ISCSI#Addressing"
+ i18n>More information</a>
+ </span>
+
+ <span class="form-text text-muted"
+ *ngIf="hasAdvancedSettings(targetForm.getValue('target_controls'))"
+ i18n>This target has modified advanced settings.</span>
+ <hr />
+ </div>
+ </div>
+
+ <!-- Portals -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="portals"
+ i18n>Portals</label>
+ <div class="cd-col-form-input">
+
+ <ng-container *ngFor="let portal of portals.value; let i = index">
+ <div class="input-group cd-mb">
+ <input class="cd-form-control"
+ type="text"
+ [value]="portal"
+ disabled />
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ type="button"
+ (click)="removePortal(i, portal)">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ </ng-container>
+
+ <div class="row">
+ <div class="col-md-12">
+ <cd-select [data]="portals.value"
+ [options]="portalsSelections"
+ [messages]="messages.portals"
+ (selection)="onPortalSelection($event)"
+ elemClass="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add portal</ng-container>
+ </cd-select>
+ </div>
+ </div>
+
+ <input class="form-control"
+ type="hidden"
+ id="portals"
+ name="portals"
+ formControlName="portals" />
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('portals', formDir, 'minGateways')"
+ i18n>At least {{ minimum_gateways }} gateways are required.</span>
+
+ <hr />
+ </div>
+ </div>
+
+ <!-- Images -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="disks"
+ i18n>Images</label>
+ <div class="cd-col-form-input">
+ <ng-container *ngFor="let image of targetForm.getValue('disks'); let i = index">
+ <div class="input-group cd-mb">
+ <input class="cd-form-control"
+ type="text"
+ [value]="image"
+ disabled />
+ <span class="input-group-append">
+ <div class="input-group-text"
+ *ngIf="api_version >= 1">lun: {{ imagesSettings[image]['lun'] }}</div>
+ <button class="btn btn-light"
+ type="button"
+ (click)="imageSettingsModal(image)">
+ <i [ngClass]="[icons.deepCheck]"
+ aria-hidden="true"></i>
+ </button>
+ <button class="btn btn-light"
+ type="button"
+ (click)="removeImage(i, image)">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+
+ </div>
+
+ <span class="form-text text-muted">
+ <ng-container *ngIf="backstores.length > 1"
+ i18n>Backstore: {{ imagesSettings[image].backstore | iscsiBackstore }}.&nbsp;</ng-container>
+
+ <ng-container *ngIf="hasAdvancedSettings(imagesSettings[image][imagesSettings[image].backstore])"
+ i18n>This image has modified settings.</ng-container>
+ </span>
+ </ng-container>
+
+ <input class="form-control"
+ type="hidden"
+ id="disks"
+ name="disks"
+ formControlName="disks" />
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('disks', formDir, 'dupLunId')"
+ i18n>Duplicated LUN numbers.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('disks', formDir, 'dupWwn')"
+ i18n>Duplicated WWN.</span>
+
+ <div class="row">
+ <div class="col-md-12">
+ <cd-select [data]="disks.value"
+ [options]="imagesSelections"
+ [messages]="messages.images"
+ (selection)="onImageSelection($event)"
+ elemClass="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add image</ng-container>
+ </cd-select>
+ </div>
+ </div>
+
+ <hr />
+ </div>
+ </div>
+
+ <!-- acl_enabled -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ formControlName="acl_enabled"
+ name="acl_enabled"
+ id="acl_enabled">
+ <label for="acl_enabled"
+ class="custom-control-label"
+ i18n>ACL authentication</label>
+ </div>
+
+ <hr />
+ </div>
+ </div>
+
+ <!-- Target level authentication was introduced in ceph-iscsi config v11 -->
+ <div formGroupName="auth"
+ *ngIf="cephIscsiConfigVersion > 10 && !targetForm.getValue('acl_enabled')">
+
+ <!-- Target user -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="target_user">
+ <ng-container i18n>User</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ autocomplete="off"
+ id="target_user"
+ name="target_user"
+ formControlName="user" />
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('user', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('user', formDir, 'pattern')"
+ i18n>User names must have a length of 8 to 64 characters and can contain
+ alphanumeric characters, '.', '@', '-', '_' or ':'.</span>
+ </div>
+ </div>
+
+ <!-- Target password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="target_password">
+ <ng-container i18n>Password</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ autocomplete="new-password"
+ id="target_password"
+ name="target_password"
+ formControlName="password" />
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="target_password">
+ </button>
+ <cd-copy-2-clipboard-button source="target_password">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('password', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('password', formDir, 'pattern')"
+ i18n>Passwords must have a length of 12 to 16 characters and can contain
+ alphanumeric characters, '@', '-', '_' or '/'.</span>
+ </div>
+ </div>
+
+ <!-- Target mutual_user -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="target_mutual_user">
+ <ng-container i18n>Mutual User</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ autocomplete="off"
+ id="target_mutual_user"
+ name="target_mutual_user"
+ formControlName="mutual_user" />
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('mutual_user', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('mutual_user', formDir, 'pattern')"
+ i18n>User names must have a length of 8 to 64 characters and can contain
+ alphanumeric characters, '.', '@', '-', '_' or ':'.</span>
+ </div>
+ </div>
+
+ <!-- Target mutual_password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="target_mutual_password">
+ <ng-container i18n>Mutual Password</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ autocomplete="new-password"
+ id="target_mutual_password"
+ name="target_mutual_password"
+ formControlName="mutual_password" />
+
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="target_mutual_password">
+ </button>
+ <cd-copy-2-clipboard-button source="target_mutual_password">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('mutual_password', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="targetForm.showError('mutual_password', formDir, 'pattern')"
+ i18n>Passwords must have a length of 12 to 16 characters and can contain
+ alphanumeric characters, '@', '-', '_' or '/'.</span>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- Initiators -->
+ <div class="form-group row"
+ *ngIf="targetForm.getValue('acl_enabled')">
+ <label class="cd-col-form-label"
+ for="initiators"
+ i18n>Initiators</label>
+ <div class="cd-col-form-input"
+ formArrayName="initiators">
+ <div class="card mb-2"
+ *ngFor="let initiator of initiators.controls; let ii = index"
+ [formGroup]="initiator">
+ <div class="card-header">
+ <ng-container i18n>Initiator</ng-container>: {{ initiator.getValue('client_iqn') }}
+ <button type="button"
+ class="close"
+ (click)="removeInitiator(ii)">
+ <i [ngClass]="[icons.destroy]"></i>
+ </button>
+ </div>
+ <div class="card-body">
+ <!-- Initiator: Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="client_iqn"
+ i18n>Client IQN</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ formControlName="client_iqn"
+ cdTrim
+ (blur)="updatedInitiatorSelector()">
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('client_iqn', formDir, 'notUnique')"
+ i18n>Initiator IQN needs to be unique.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('client_iqn', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('client_iqn', formDir, 'pattern')"
+ i18n>IQN has wrong pattern.</span>
+ </div>
+ </div>
+
+ <ng-container formGroupName="auth">
+ <!-- Initiator: User -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="user"
+ i18n>User</label>
+ <div class="cd-col-form-input">
+ <input [id]="'user' + ii"
+ class="form-control"
+ formControlName="user"
+ autocomplete="off"
+ type="text">
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('user', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('user', formDir, 'pattern')"
+ i18n>User names must have a length of 8 to 64 characters and can contain
+ alphanumeric characters, '.', '@', '-', '_' or ':'.</span>
+ </div>
+ </div>
+
+ <!-- Initiator: Password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="password"
+ i18n>Password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input [id]="'password' + ii"
+ class="form-control"
+ formControlName="password"
+ autocomplete="new-password"
+ type="password">
+
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ [cdPasswordButton]="'password' + ii">
+ </button>
+ <cd-copy-2-clipboard-button [source]="'password' + ii">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('password', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('password', formDir, 'pattern')"
+ i18n>Passwords must have a length of 12 to 16 characters and can contain
+ alphanumeric characters, '@', '-', '_' or '/'.</span>
+ </div>
+ </div>
+
+
+ <!-- Initiator: mutual_user -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="mutual_user">
+ <ng-container i18n>Mutual User</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <input [id]="'mutual_user' + ii"
+ class="form-control"
+ formControlName="mutual_user"
+ autocomplete="off"
+ type="text">
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('mutual_user', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('mutual_user', formDir, 'pattern')"
+ i18n>User names must have a length of 8 to 64 characters and can contain
+ alphanumeric characters, '.', '@', '-', '_' or ':'.</span>
+ </div>
+ </div>
+
+ <!-- Initiator: mutual_password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="mutual_password"
+ i18n>Mutual Password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input [id]="'mutual_password' + ii"
+ class="form-control"
+ formControlName="mutual_password"
+ autocomplete="new-password"
+ type="password">
+
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ [cdPasswordButton]="'mutual_password' + ii">
+ </button>
+ <cd-copy-2-clipboard-button [source]="'mutual_password' + ii">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('mutual_password', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="initiator.showError('mutual_password', formDir, 'pattern')"
+ i18n>Passwords must have a length of 12 to 16 characters and can contain
+ alphanumeric characters, '@', '-', '_' or '/'.</span>
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- Initiator: Images -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="luns"
+ i18n>Images</label>
+ <div class="cd-col-form-input">
+ <ng-container *ngFor="let image of initiator.getValue('luns'); let li = index">
+ <div class="input-group cd-mb">
+ <input class="cd-form-control"
+ type="text"
+ [value]="image"
+ disabled />
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ type="button"
+ (click)="removeInitiatorImage(initiator, li, ii, image)">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ </ng-container>
+
+ <span *ngIf="initiator.getValue('cdIsInGroup')"
+ i18n>Initiator belongs to a group. Images will be configure in the group.</span>
+
+ <div class="row"
+ *ngIf="!initiator.getValue('cdIsInGroup')">
+ <div class="col-md-12">
+ <cd-select [data]="initiator.getValue('luns')"
+ [options]="imagesInitiatorSelections[ii]"
+ [messages]="messages.initiatorImage"
+ elemClass="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add image</ng-container>
+ </cd-select>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-12">
+ <span class="form-text text-muted"
+ *ngIf="initiators.controls.length === 0"
+ i18n>No items added.</span>
+
+ <button (click)="addInitiator(); false"
+ class="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add initiator</ng-container>
+ </button>
+ </div>
+ </div>
+
+ <hr />
+ </div>
+ </div>
+
+ <!-- Groups -->
+ <div class="form-group row"
+ *ngIf="targetForm.getValue('acl_enabled')">
+ <label class="cd-col-form-label"
+ for="initiators"
+ i18n>Groups</label>
+ <div class="cd-col-form-input"
+ formArrayName="groups">
+ <div class="card mb-2"
+ *ngFor="let group of groups.controls; let gi = index"
+ [formGroup]="group">
+ <div class="card-header">
+ <ng-container i18n>Group</ng-container>: {{ group.getValue('group_id') }}
+ <button type="button"
+ class="close"
+ (click)="removeGroup(gi)">
+ <i [ngClass]="[icons.destroy]"></i>
+ </button>
+ </div>
+ <div class="card-body">
+ <!-- Group: group_id -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="group_id"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ formControlName="group_id">
+ </div>
+ </div>
+
+ <!-- Group: members -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="members">
+ <ng-container i18n>Initiators</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <ng-container *ngFor="let member of group.getValue('members'); let i = index">
+ <div class="input-group cd-mb">
+ <input class="cd-form-control"
+ type="text"
+ [value]="member"
+ disabled />
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ type="button"
+ (click)="removeGroupInitiator(group, i, gi)">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ </ng-container>
+
+ <div class="row">
+ <div class="col-md-12">
+ <cd-select [data]="group.getValue('members')"
+ [options]="groupMembersSelections[gi]"
+ [messages]="messages.groupInitiator"
+ (selection)="onGroupMemberSelection($event, gi)"
+ elemClass="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add initiator</ng-container>
+ </cd-select>
+ </div>
+ </div>
+
+ <hr />
+ </div>
+ </div>
+
+ <!-- Group: disks -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="disks">
+ <ng-container i18n>Images</ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <ng-container *ngFor="let disk of group.getValue('disks'); let i = index">
+ <div class="input-group cd-mb">
+ <input class="cd-form-control"
+ type="text"
+ [value]="disk"
+ disabled />
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ type="button"
+ (click)="removeGroupDisk(group, i, gi)">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ </ng-container>
+
+ <div class="row">
+ <div class="col-md-12">
+ <cd-select [data]="group.getValue('disks')"
+ [options]="groupDiskSelections[gi]"
+ [messages]="messages.initiatorImage"
+ elemClass="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add image</ng-container>
+ </cd-select>
+ </div>
+ </div>
+
+ <hr />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-12">
+ <span class="form-text text-muted"
+ *ngIf="groups.controls.length === 0"
+ i18n>No items added.</span>
+
+ <button (click)="addGroup(); false"
+ class="btn btn-light float-right">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add group</ng-container>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="targetForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss
new file mode 100644
index 000000000..cebcc8877
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss
@@ -0,0 +1,3 @@
+.cd-mb {
+ margin-bottom: 10px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts
new file mode 100644
index 000000000..e993468c8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts
@@ -0,0 +1,593 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { configureTestBed, FormHelper, IscsiHelper } from '~/testing/unit-test-helper';
+import { IscsiTargetFormComponent } from './iscsi-target-form.component';
+
+describe('IscsiTargetFormComponent', () => {
+ let component: IscsiTargetFormComponent;
+ let fixture: ComponentFixture<IscsiTargetFormComponent>;
+ let httpTesting: HttpTestingController;
+ let activatedRoute: ActivatedRouteStub;
+
+ const SETTINGS = {
+ config: { minimum_gateways: 2 },
+ disk_default_controls: {
+ 'backstore:1': {
+ hw_max_sectors: 1024,
+ osd_op_timeout: 30
+ },
+ 'backstore:2': {
+ qfull_timeout: 5
+ }
+ },
+ target_default_controls: {
+ cmdsn_depth: 128,
+ dataout_timeout: 20,
+ immediate_data: true
+ },
+ required_rbd_features: {
+ 'backstore:1': 0,
+ 'backstore:2': 0
+ },
+ unsupported_rbd_features: {
+ 'backstore:1': 0,
+ 'backstore:2': 0
+ },
+ backstores: ['backstore:1', 'backstore:2'],
+ default_backstore: 'backstore:1',
+ api_version: 1
+ };
+
+ const LIST_TARGET: any[] = [
+ {
+ target_iqn: 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw',
+ portals: [{ host: 'node1', ip: '192.168.100.201' }],
+ disks: [
+ {
+ pool: 'rbd',
+ image: 'disk_1',
+ controls: {},
+ backstore: 'backstore:1',
+ wwn: '64af6678-9694-4367-bacc-f8eb0baa'
+ }
+ ],
+ clients: [
+ {
+ client_iqn: 'iqn.1994-05.com.redhat:rh7-client',
+ luns: [{ pool: 'rbd', image: 'disk_1', lun: 0 }],
+ auth: {
+ user: 'myiscsiusername',
+ password: 'myiscsipassword',
+ mutual_user: null,
+ mutual_password: null
+ }
+ }
+ ],
+ groups: [],
+ target_controls: {}
+ }
+ ];
+
+ const PORTALS = [
+ { name: 'node1', ip_addresses: ['192.168.100.201', '10.0.2.15'] },
+ { name: 'node2', ip_addresses: ['192.168.100.202'] }
+ ];
+
+ const VERSION = {
+ ceph_iscsi_config_version: 11
+ };
+
+ const RBD_LIST: any[] = [
+ { value: [], pool_name: 'ganesha' },
+ {
+ value: [
+ {
+ size: 96636764160,
+ obj_size: 4194304,
+ num_objs: 23040,
+ order: 22,
+ block_name_prefix: 'rbd_data.148162fb31a8',
+ name: 'disk_1',
+ id: '148162fb31a8',
+ pool_name: 'rbd',
+ features: 61,
+ features_name: ['deep-flatten', 'exclusive-lock', 'fast-diff', 'layering', 'object-map'],
+ timestamp: '2019-01-18T10:44:26Z',
+ stripe_count: 1,
+ stripe_unit: 4194304,
+ data_pool: null,
+ parent: null,
+ snapshots: [],
+ total_disk_usage: 0,
+ disk_usage: 0
+ },
+ {
+ size: 119185342464,
+ obj_size: 4194304,
+ num_objs: 28416,
+ order: 22,
+ block_name_prefix: 'rbd_data.14b292cee6cb',
+ name: 'disk_2',
+ id: '14b292cee6cb',
+ pool_name: 'rbd',
+ features: 61,
+ features_name: ['deep-flatten', 'exclusive-lock', 'fast-diff', 'layering', 'object-map'],
+ timestamp: '2019-01-18T10:45:56Z',
+ stripe_count: 1,
+ stripe_unit: 4194304,
+ data_pool: null,
+ parent: null,
+ snapshots: [],
+ total_disk_usage: 0,
+ disk_usage: 0
+ }
+ ],
+ pool_name: 'rbd'
+ }
+ ];
+
+ configureTestBed(
+ {
+ declarations: [IscsiTargetFormComponent],
+ imports: [
+ SharedModule,
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: new ActivatedRouteStub({ target_iqn: undefined })
+ }
+ ]
+ },
+ [LoadingPanelComponent]
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTargetFormComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.inject(HttpTestingController);
+ activatedRoute = <ActivatedRouteStub>TestBed.inject(ActivatedRoute);
+ fixture.detectChanges();
+
+ httpTesting.expectOne('ui-api/iscsi/settings').flush(SETTINGS);
+ httpTesting.expectOne('ui-api/iscsi/portals').flush(PORTALS);
+ httpTesting.expectOne('ui-api/iscsi/version').flush(VERSION);
+ httpTesting.expectOne('api/block/image?offset=0&limit=-1&search=&sort=+name').flush(RBD_LIST);
+ httpTesting.expectOne('api/iscsi/target').flush(LIST_TARGET);
+ httpTesting.verify();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should only show images not used in other targets', () => {
+ expect(component.imagesAll).toEqual([RBD_LIST[1]['value'][1]]);
+ expect(component.imagesSelections).toEqual([
+ { description: '', name: 'rbd/disk_2', selected: false, enabled: true }
+ ]);
+ });
+
+ it('should generate portals selectOptions', () => {
+ expect(component.portalsSelections).toEqual([
+ { description: '', name: 'node1:192.168.100.201', selected: false, enabled: true },
+ { description: '', name: 'node1:10.0.2.15', selected: false, enabled: true },
+ { description: '', name: 'node2:192.168.100.202', selected: false, enabled: true }
+ ]);
+ });
+
+ it('should create the form', () => {
+ expect(component.targetForm.value).toEqual({
+ disks: [],
+ groups: [],
+ initiators: [],
+ acl_enabled: false,
+ auth: {
+ password: '',
+ user: '',
+ mutual_password: '',
+ mutual_user: ''
+ },
+ portals: [],
+ target_controls: {},
+ target_iqn: component.targetForm.value.target_iqn
+ });
+ });
+
+ it('should prepare data when selecting an image', () => {
+ expect(component.imagesSettings).toEqual({});
+ component.onImageSelection({ option: { name: 'rbd/disk_2', selected: true } });
+ expect(component.imagesSettings).toEqual({
+ 'rbd/disk_2': {
+ lun: 0,
+ backstore: 'backstore:1',
+ 'backstore:1': {}
+ }
+ });
+ });
+
+ it('should clean data when removing an image', () => {
+ component.onImageSelection({ option: { name: 'rbd/disk_2', selected: true } });
+ component.addGroup();
+ component.groups.controls[0].patchValue({
+ group_id: 'foo',
+ disks: ['rbd/disk_2']
+ });
+
+ expect(component.groups.controls[0].value).toEqual({
+ disks: ['rbd/disk_2'],
+ group_id: 'foo',
+ members: []
+ });
+
+ component.onImageSelection({ option: { name: 'rbd/disk_2', selected: false } });
+
+ expect(component.groups.controls[0].value).toEqual({ disks: [], group_id: 'foo', members: [] });
+ expect(component.imagesSettings).toEqual({
+ 'rbd/disk_2': {
+ lun: 0,
+ backstore: 'backstore:1',
+ 'backstore:1': {}
+ }
+ });
+ });
+
+ it('should validate authentication', () => {
+ const control = component.targetForm;
+ const formHelper = new FormHelper(control as CdFormGroup);
+ formHelper.expectValid('auth');
+ validateAuth(formHelper);
+ });
+
+ describe('should test initiators', () => {
+ beforeEach(() => {
+ component.onImageSelection({ option: { name: 'rbd/disk_2', selected: true } });
+ component.targetForm.patchValue({ disks: ['rbd/disk_2'], acl_enabled: true });
+ component.addGroup().patchValue({ name: 'group_1' });
+ component.addGroup().patchValue({ name: 'group_2' });
+
+ component.addInitiator();
+ component.initiators.controls[0].patchValue({
+ client_iqn: 'iqn.initiator'
+ });
+ component.updatedInitiatorSelector();
+ });
+
+ it('should prepare data when creating an initiator', () => {
+ expect(component.initiators.controls.length).toBe(1);
+ expect(component.initiators.controls[0].value).toEqual({
+ auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+ cdIsInGroup: false,
+ client_iqn: 'iqn.initiator',
+ luns: []
+ });
+ expect(component.imagesInitiatorSelections).toEqual([
+ [{ description: '', name: 'rbd/disk_2', selected: false, enabled: true }]
+ ]);
+ expect(component.groupMembersSelections).toEqual([
+ [{ description: '', name: 'iqn.initiator', selected: false, enabled: true }],
+ [{ description: '', name: 'iqn.initiator', selected: false, enabled: true }]
+ ]);
+ });
+
+ it('should update data when changing an initiator name', () => {
+ expect(component.groupMembersSelections).toEqual([
+ [{ description: '', name: 'iqn.initiator', selected: false, enabled: true }],
+ [{ description: '', name: 'iqn.initiator', selected: false, enabled: true }]
+ ]);
+
+ component.initiators.controls[0].patchValue({
+ client_iqn: 'iqn.initiator_new'
+ });
+ component.updatedInitiatorSelector();
+
+ expect(component.groupMembersSelections).toEqual([
+ [{ description: '', name: 'iqn.initiator_new', selected: false, enabled: true }],
+ [{ description: '', name: 'iqn.initiator_new', selected: false, enabled: true }]
+ ]);
+ });
+
+ it('should clean data when removing an initiator', () => {
+ component.groups.controls[0].patchValue({
+ group_id: 'foo',
+ members: ['iqn.initiator']
+ });
+
+ expect(component.groups.controls[0].value).toEqual({
+ disks: [],
+ group_id: 'foo',
+ members: ['iqn.initiator']
+ });
+
+ component.removeInitiator(0);
+
+ expect(component.groups.controls[0].value).toEqual({
+ disks: [],
+ group_id: 'foo',
+ members: []
+ });
+ expect(component.groupMembersSelections).toEqual([[], []]);
+ expect(component.imagesInitiatorSelections).toEqual([]);
+ });
+
+ it('should remove images in the initiator when added in a group', () => {
+ component.initiators.controls[0].patchValue({
+ luns: ['rbd/disk_2']
+ });
+ component.imagesInitiatorSelections[0] = [
+ {
+ description: '',
+ enabled: true,
+ name: 'rbd/disk_2',
+ selected: true
+ }
+ ];
+ expect(component.initiators.controls[0].value).toEqual({
+ auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+ cdIsInGroup: false,
+ client_iqn: 'iqn.initiator',
+ luns: ['rbd/disk_2']
+ });
+
+ component.groups.controls[0].patchValue({
+ group_id: 'foo',
+ members: ['iqn.initiator']
+ });
+ component.onGroupMemberSelection(
+ {
+ option: {
+ name: 'iqn.initiator',
+ selected: true
+ }
+ },
+ 0
+ );
+
+ expect(component.initiators.controls[0].value).toEqual({
+ auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+ cdIsInGroup: true,
+ client_iqn: 'iqn.initiator',
+ luns: []
+ });
+ expect(component.imagesInitiatorSelections[0]).toEqual([
+ {
+ description: '',
+ enabled: true,
+ name: 'rbd/disk_2',
+ selected: false
+ }
+ ]);
+ });
+
+ it('should disabled the initiator when selected', () => {
+ expect(component.groupMembersSelections).toEqual([
+ [{ description: '', enabled: true, name: 'iqn.initiator', selected: false }],
+ [{ description: '', enabled: true, name: 'iqn.initiator', selected: false }]
+ ]);
+
+ component.groupMembersSelections[0][0].selected = true;
+ component.onGroupMemberSelection({ option: { name: 'iqn.initiator', selected: true } }, 0);
+
+ expect(component.groupMembersSelections).toEqual([
+ [{ description: '', enabled: false, name: 'iqn.initiator', selected: true }],
+ [{ description: '', enabled: false, name: 'iqn.initiator', selected: false }]
+ ]);
+ });
+
+ describe('should remove from group', () => {
+ beforeEach(() => {
+ component.onGroupMemberSelection(
+ { option: new SelectOption(true, 'iqn.initiator', '') },
+ 0
+ );
+ component.groupDiskSelections[0][0].selected = true;
+ component.groups.controls[0].patchValue({
+ disks: ['rbd/disk_2'],
+ members: ['iqn.initiator']
+ });
+
+ expect(component.initiators.value[0].luns).toEqual([]);
+ expect(component.imagesInitiatorSelections[0]).toEqual([
+ { description: '', enabled: true, name: 'rbd/disk_2', selected: false }
+ ]);
+ expect(component.initiators.value[0].cdIsInGroup).toBe(true);
+ });
+
+ it('should update initiator images when deselecting', () => {
+ component.onGroupMemberSelection(
+ { option: new SelectOption(false, 'iqn.initiator', '') },
+ 0
+ );
+
+ expect(component.initiators.value[0].luns).toEqual(['rbd/disk_2']);
+ expect(component.imagesInitiatorSelections[0]).toEqual([
+ { description: '', enabled: true, name: 'rbd/disk_2', selected: true }
+ ]);
+ expect(component.initiators.value[0].cdIsInGroup).toBe(false);
+ });
+
+ it('should update initiator when removing', () => {
+ component.removeGroupInitiator(component.groups.controls[0] as CdFormGroup, 0, 0);
+
+ expect(component.initiators.value[0].luns).toEqual(['rbd/disk_2']);
+ expect(component.imagesInitiatorSelections[0]).toEqual([
+ { description: '', enabled: true, name: 'rbd/disk_2', selected: true }
+ ]);
+ expect(component.initiators.value[0].cdIsInGroup).toBe(false);
+ });
+ });
+
+ it('should validate authentication', () => {
+ const control = component.initiators.controls[0];
+ const formHelper = new FormHelper(control as CdFormGroup);
+ formHelper.expectValid(control);
+ validateAuth(formHelper);
+ });
+ });
+
+ describe('should submit request', () => {
+ beforeEach(() => {
+ component.onImageSelection({ option: { name: 'rbd/disk_2', selected: true } });
+ component.targetForm.patchValue({ disks: ['rbd/disk_2'], acl_enabled: true });
+ component.portals.setValue(['node1:192.168.100.201', 'node2:192.168.100.202']);
+ component.addInitiator().patchValue({
+ client_iqn: 'iqn.initiator'
+ });
+ component.addGroup().patchValue({
+ group_id: 'foo',
+ members: ['iqn.initiator'],
+ disks: ['rbd/disk_2']
+ });
+ });
+
+ it('should call update', () => {
+ activatedRoute.setParams({ target_iqn: 'iqn.iscsi' });
+ component.isEdit = true;
+ component.target_iqn = 'iqn.iscsi';
+
+ component.submit();
+
+ const req = httpTesting.expectOne('api/iscsi/target/iqn.iscsi');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({
+ clients: [
+ {
+ auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+ client_iqn: 'iqn.initiator',
+ luns: []
+ }
+ ],
+ disks: [
+ {
+ backstore: 'backstore:1',
+ controls: {},
+ image: 'disk_2',
+ pool: 'rbd',
+ lun: 0,
+ wwn: undefined
+ }
+ ],
+ groups: [
+ { disks: [{ image: 'disk_2', pool: 'rbd' }], group_id: 'foo', members: ['iqn.initiator'] }
+ ],
+ new_target_iqn: component.targetForm.value.target_iqn,
+ portals: [
+ { host: 'node1', ip: '192.168.100.201' },
+ { host: 'node2', ip: '192.168.100.202' }
+ ],
+ target_controls: {},
+ target_iqn: component.target_iqn,
+ acl_enabled: true,
+ auth: {
+ password: '',
+ user: '',
+ mutual_password: '',
+ mutual_user: ''
+ }
+ });
+ });
+
+ it('should call create', () => {
+ component.submit();
+
+ const req = httpTesting.expectOne('api/iscsi/target');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({
+ clients: [
+ {
+ auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+ client_iqn: 'iqn.initiator',
+ luns: []
+ }
+ ],
+ disks: [
+ {
+ backstore: 'backstore:1',
+ controls: {},
+ image: 'disk_2',
+ pool: 'rbd',
+ lun: 0,
+ wwn: undefined
+ }
+ ],
+ groups: [
+ {
+ disks: [{ image: 'disk_2', pool: 'rbd' }],
+ group_id: 'foo',
+ members: ['iqn.initiator']
+ }
+ ],
+ portals: [
+ { host: 'node1', ip: '192.168.100.201' },
+ { host: 'node2', ip: '192.168.100.202' }
+ ],
+ target_controls: {},
+ target_iqn: component.targetForm.value.target_iqn,
+ acl_enabled: true,
+ auth: {
+ password: '',
+ user: '',
+ mutual_password: '',
+ mutual_user: ''
+ }
+ });
+ });
+
+ it('should call create with acl_enabled disabled', () => {
+ component.targetForm.patchValue({ acl_enabled: false });
+ component.submit();
+
+ const req = httpTesting.expectOne('api/iscsi/target');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({
+ clients: [],
+ disks: [
+ {
+ backstore: 'backstore:1',
+ controls: {},
+ image: 'disk_2',
+ pool: 'rbd',
+ lun: 0,
+ wwn: undefined
+ }
+ ],
+ groups: [],
+ acl_enabled: false,
+ auth: {
+ password: '',
+ user: '',
+ mutual_password: '',
+ mutual_user: ''
+ },
+ portals: [
+ { host: 'node1', ip: '192.168.100.201' },
+ { host: 'node2', ip: '192.168.100.202' }
+ ],
+ target_controls: {},
+ target_iqn: component.targetForm.value.target_iqn
+ });
+ });
+ });
+
+ function validateAuth(formHelper: FormHelper) {
+ IscsiHelper.validateUser(formHelper, 'auth.user');
+ IscsiHelper.validatePassword(formHelper, 'auth.password');
+ IscsiHelper.validateUser(formHelper, 'auth.mutual_user');
+ IscsiHelper.validatePassword(formHelper, 'auth.mutual_password');
+ }
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts
new file mode 100644
index 000000000..6704b41e6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts
@@ -0,0 +1,822 @@
+import { Component, OnInit } from '@angular/core';
+import { FormArray, FormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { forkJoin } from 'rxjs';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { IscsiTargetImageSettingsModalComponent } from '../iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component';
+import { IscsiTargetIqnSettingsModalComponent } from '../iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component';
+
+@Component({
+ selector: 'cd-iscsi-target-form',
+ templateUrl: './iscsi-target-form.component.html',
+ styleUrls: ['./iscsi-target-form.component.scss']
+})
+export class IscsiTargetFormComponent extends CdForm implements OnInit {
+ cephIscsiConfigVersion: number;
+ targetForm: CdFormGroup;
+ modalRef: NgbModalRef;
+ api_version = 0;
+ minimum_gateways = 1;
+ target_default_controls: any;
+ target_controls_limits: any;
+ disk_default_controls: any;
+ disk_controls_limits: any;
+ backstores: string[];
+ default_backstore: string;
+ unsupported_rbd_features: any;
+ required_rbd_features: any;
+
+ icons = Icons;
+
+ isEdit = false;
+ target_iqn: string;
+
+ imagesAll: any[];
+ imagesSelections: SelectOption[];
+ portalsSelections: SelectOption[] = [];
+
+ imagesInitiatorSelections: SelectOption[][] = [];
+ groupDiskSelections: SelectOption[][] = [];
+ groupMembersSelections: SelectOption[][] = [];
+
+ imagesSettings: any = {};
+ messages = {
+ portals: new SelectMessages({ noOptions: $localize`There are no portals available.` }),
+ images: new SelectMessages({ noOptions: $localize`There are no images available.` }),
+ initiatorImage: new SelectMessages({
+ noOptions: $localize`There are no images available. Please make sure you add an image to the target.`
+ }),
+ groupInitiator: new SelectMessages({
+ noOptions: $localize`There are no initiators available. Please make sure you add an initiator to the target.`
+ })
+ };
+
+ IQN_REGEX = /^iqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+)*$/;
+ USER_REGEX = /^[\w\.:@_-]{8,64}$/;
+ PASSWORD_REGEX = /^[\w@\-_\/]{12,16}$/;
+ action: string;
+ resource: string;
+
+ constructor(
+ private iscsiService: IscsiService,
+ private modalService: ModalService,
+ private rbdService: RbdService,
+ private router: Router,
+ private route: ActivatedRoute,
+ private taskWrapper: TaskWrapperService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.resource = $localize`target`;
+ }
+
+ ngOnInit() {
+ const rbdListContext = new CdTableFetchDataContext(() => undefined);
+ /* limit -1 to specify all images */
+ rbdListContext.pageInfo.limit = -1;
+ const promises: any[] = [
+ this.iscsiService.listTargets(),
+ /* tslint:disable:no-empty */
+ this.rbdService.list(rbdListContext.toParams()),
+ this.iscsiService.portals(),
+ this.iscsiService.settings(),
+ this.iscsiService.version()
+ ];
+
+ if (this.router.url.startsWith('/block/iscsi/targets/edit')) {
+ this.isEdit = true;
+ this.route.params.subscribe((params: { target_iqn: string }) => {
+ this.target_iqn = decodeURIComponent(params.target_iqn);
+ promises.push(this.iscsiService.getTarget(this.target_iqn));
+ });
+ }
+ this.action = this.isEdit ? this.actionLabels.EDIT : this.actionLabels.CREATE;
+
+ forkJoin(promises).subscribe((data: any[]) => {
+ // iscsiService.listTargets
+ const usedImages = _(data[0])
+ .filter((target) => target.target_iqn !== this.target_iqn)
+ .flatMap((target) => target.disks)
+ .map((image) => `${image.pool}/${image.image}`)
+ .value();
+
+ // iscsiService.settings()
+ if ('api_version' in data[3]) {
+ this.api_version = data[3].api_version;
+ }
+ this.minimum_gateways = data[3].config.minimum_gateways;
+ this.target_default_controls = data[3].target_default_controls;
+ this.target_controls_limits = data[3].target_controls_limits;
+ this.disk_default_controls = data[3].disk_default_controls;
+ this.disk_controls_limits = data[3].disk_controls_limits;
+ this.backstores = data[3].backstores;
+ this.default_backstore = data[3].default_backstore;
+ this.unsupported_rbd_features = data[3].unsupported_rbd_features;
+ this.required_rbd_features = data[3].required_rbd_features;
+
+ // rbdService.list()
+ this.imagesAll = _(data[1])
+ .flatMap((pool) => pool.value)
+ .filter((image) => {
+ // Namespaces are not supported by ceph-iscsi
+ if (image.namespace) {
+ return false;
+ }
+ const imageId = `${image.pool_name}/${image.name}`;
+ if (usedImages.indexOf(imageId) !== -1) {
+ return false;
+ }
+ const validBackstores = this.getValidBackstores(image);
+ if (validBackstores.length === 0) {
+ return false;
+ }
+ return true;
+ })
+ .value();
+
+ this.imagesSelections = this.imagesAll.map(
+ (image) => new SelectOption(false, `${image.pool_name}/${image.name}`, '')
+ );
+
+ // iscsiService.portals()
+ const portals: SelectOption[] = [];
+ data[2].forEach((portal: Record<string, any>) => {
+ portal.ip_addresses.forEach((ip: string) => {
+ portals.push(new SelectOption(false, portal.name + ':' + ip, ''));
+ });
+ });
+ this.portalsSelections = [...portals];
+
+ // iscsiService.version()
+ this.cephIscsiConfigVersion = data[4]['ceph_iscsi_config_version'];
+
+ this.createForm();
+
+ // iscsiService.getTarget()
+ if (data[5]) {
+ this.resolveModel(data[5]);
+ }
+
+ this.loadingReady();
+ });
+ }
+
+ createForm() {
+ this.targetForm = new CdFormGroup({
+ target_iqn: new FormControl('iqn.2001-07.com.ceph:' + Date.now(), {
+ validators: [Validators.required, Validators.pattern(this.IQN_REGEX)]
+ }),
+ target_controls: new FormControl({}),
+ portals: new FormControl([], {
+ validators: [
+ CdValidators.custom('minGateways', (value: any[]) => {
+ const gateways = _.uniq(value.map((elem) => elem.split(':')[0]));
+ return gateways.length < Math.max(1, this.minimum_gateways);
+ })
+ ]
+ }),
+ disks: new FormControl([], {
+ validators: [
+ CdValidators.custom('dupLunId', (value: any[]) => {
+ const lunIds = this.getLunIds(value);
+ return lunIds.length !== _.uniq(lunIds).length;
+ }),
+ CdValidators.custom('dupWwn', (value: any[]) => {
+ const wwns = this.getWwns(value);
+ return wwns.length !== _.uniq(wwns).length;
+ })
+ ]
+ }),
+ initiators: new FormArray([]),
+ groups: new FormArray([]),
+ acl_enabled: new FormControl(false)
+ });
+ // Target level authentication was introduced in ceph-iscsi config v11
+ if (this.cephIscsiConfigVersion > 10) {
+ const authFormGroup = new CdFormGroup({
+ user: new FormControl(''),
+ password: new FormControl(''),
+ mutual_user: new FormControl(''),
+ mutual_password: new FormControl('')
+ });
+ this.setAuthValidator(authFormGroup);
+ this.targetForm.addControl('auth', authFormGroup);
+ }
+ }
+
+ resolveModel(res: Record<string, any>) {
+ this.targetForm.patchValue({
+ target_iqn: res.target_iqn,
+ target_controls: res.target_controls,
+ acl_enabled: res.acl_enabled
+ });
+ // Target level authentication was introduced in ceph-iscsi config v11
+ if (this.cephIscsiConfigVersion > 10) {
+ this.targetForm.patchValue({
+ auth: res.auth
+ });
+ }
+ const portals: any[] = [];
+ _.forEach(res.portals, (portal) => {
+ const id = `${portal.host}:${portal.ip}`;
+ portals.push(id);
+ });
+ this.targetForm.patchValue({
+ portals: portals
+ });
+
+ const disks: any[] = [];
+ _.forEach(res.disks, (disk) => {
+ const id = `${disk.pool}/${disk.image}`;
+ disks.push(id);
+ this.imagesSettings[id] = {
+ backstore: disk.backstore
+ };
+ this.imagesSettings[id][disk.backstore] = disk.controls;
+ if ('lun' in disk) {
+ this.imagesSettings[id]['lun'] = disk.lun;
+ }
+ if ('wwn' in disk) {
+ this.imagesSettings[id]['wwn'] = disk.wwn;
+ }
+
+ this.onImageSelection({ option: { name: id, selected: true } });
+ });
+ this.targetForm.patchValue({
+ disks: disks
+ });
+
+ _.forEach(res.clients, (client) => {
+ const initiator = this.addInitiator();
+ client.luns = _.map(client.luns, (lun) => `${lun.pool}/${lun.image}`);
+ initiator.patchValue(client);
+ // updatedInitiatorSelector()
+ });
+
+ (res.groups as any[]).forEach((group: any, group_index: number) => {
+ const fg = this.addGroup();
+ group.disks = _.map(group.disks, (disk) => `${disk.pool}/${disk.image}`);
+ fg.patchValue(group);
+ _.forEach(group.members, (member) => {
+ this.onGroupMemberSelection({ option: new SelectOption(true, member, '') }, group_index);
+ });
+ });
+ }
+
+ hasAdvancedSettings(settings: any) {
+ return Object.values(settings).length > 0;
+ }
+
+ // Portals
+ get portals() {
+ return this.targetForm.get('portals') as FormControl;
+ }
+
+ onPortalSelection() {
+ this.portals.setValue(this.portals.value);
+ }
+
+ removePortal(index: number, portal: string) {
+ this.portalsSelections.forEach((value) => {
+ if (value.name === portal) {
+ value.selected = false;
+ }
+ });
+
+ this.portals.value.splice(index, 1);
+ this.portals.setValue(this.portals.value);
+ return false;
+ }
+
+ // Images
+ get disks() {
+ return this.targetForm.get('disks') as FormControl;
+ }
+
+ removeImage(index: number, image: string) {
+ this.imagesSelections.forEach((value) => {
+ if (value.name === image) {
+ value.selected = false;
+ }
+ });
+ this.disks.value.splice(index, 1);
+ this.removeImageRefs(image);
+ this.targetForm.get('disks').updateValueAndValidity({ emitEvent: false });
+ return false;
+ }
+
+ removeImageRefs(name: string) {
+ this.initiators.controls.forEach((element) => {
+ const newImages = element.value.luns.filter((item: string) => item !== name);
+ element.get('luns').setValue(newImages);
+ });
+
+ this.groups.controls.forEach((element) => {
+ const newDisks = element.value.disks.filter((item: string) => item !== name);
+ element.get('disks').setValue(newDisks);
+ });
+
+ _.forEach(this.imagesInitiatorSelections, (selections, i) => {
+ this.imagesInitiatorSelections[i] = selections.filter((item: any) => item.name !== name);
+ });
+ _.forEach(this.groupDiskSelections, (selections, i) => {
+ this.groupDiskSelections[i] = selections.filter((item: any) => item.name !== name);
+ });
+ }
+
+ getDefaultBackstore(imageId: string) {
+ let result = this.default_backstore;
+ const image = this.getImageById(imageId);
+ if (!this.validFeatures(image, this.default_backstore)) {
+ this.backstores.forEach((backstore) => {
+ if (backstore !== this.default_backstore) {
+ if (this.validFeatures(image, backstore)) {
+ result = backstore;
+ }
+ }
+ });
+ }
+ return result;
+ }
+
+ isLunIdInUse(lunId: string, imageId: string) {
+ const images = this.disks.value.filter((currentImageId: string) => currentImageId !== imageId);
+ return this.getLunIds(images).includes(lunId);
+ }
+
+ getLunIds(images: object) {
+ return _.map(images, (image) => this.imagesSettings[image]['lun']);
+ }
+
+ nextLunId(imageId: string) {
+ const images = this.disks.value.filter((currentImageId: string) => currentImageId !== imageId);
+ const lunIdsInUse = this.getLunIds(images);
+ let lunIdCandidate = 0;
+ while (lunIdsInUse.includes(lunIdCandidate)) {
+ lunIdCandidate++;
+ }
+ return lunIdCandidate;
+ }
+
+ getWwns(images: object) {
+ const wwns = _.map(images, (image) => this.imagesSettings[image]['wwn']);
+ return wwns.filter((wwn) => _.isString(wwn) && wwn !== '');
+ }
+
+ onImageSelection($event: any) {
+ const option = $event.option;
+
+ if (option.selected) {
+ if (!this.imagesSettings[option.name]) {
+ const defaultBackstore = this.getDefaultBackstore(option.name);
+ this.imagesSettings[option.name] = {
+ backstore: defaultBackstore,
+ lun: this.nextLunId(option.name)
+ };
+ this.imagesSettings[option.name][defaultBackstore] = {};
+ } else if (this.isLunIdInUse(this.imagesSettings[option.name]['lun'], option.name)) {
+ // If the lun id is now in use, we have to generate a new one
+ this.imagesSettings[option.name]['lun'] = this.nextLunId(option.name);
+ }
+
+ _.forEach(this.imagesInitiatorSelections, (selections, i) => {
+ selections.push(new SelectOption(false, option.name, ''));
+ this.imagesInitiatorSelections[i] = [...selections];
+ });
+
+ _.forEach(this.groupDiskSelections, (selections, i) => {
+ selections.push(new SelectOption(false, option.name, ''));
+ this.groupDiskSelections[i] = [...selections];
+ });
+ } else {
+ this.removeImageRefs(option.name);
+ }
+ this.targetForm.get('disks').updateValueAndValidity({ emitEvent: false });
+ }
+
+ // Initiators
+ get initiators() {
+ return this.targetForm.get('initiators') as FormArray;
+ }
+
+ addInitiator() {
+ const fg = new CdFormGroup({
+ client_iqn: new FormControl('', {
+ validators: [
+ Validators.required,
+ CdValidators.custom('notUnique', (client_iqn: string) => {
+ const flattened = this.initiators.controls.reduce(function (accumulator, currentValue) {
+ return accumulator.concat(currentValue.value.client_iqn);
+ }, []);
+
+ return flattened.indexOf(client_iqn) !== flattened.lastIndexOf(client_iqn);
+ }),
+ Validators.pattern(this.IQN_REGEX)
+ ]
+ }),
+ auth: new CdFormGroup({
+ user: new FormControl(''),
+ password: new FormControl(''),
+ mutual_user: new FormControl(''),
+ mutual_password: new FormControl('')
+ }),
+ luns: new FormControl([]),
+ cdIsInGroup: new FormControl(false)
+ });
+
+ this.setAuthValidator(fg);
+
+ this.initiators.push(fg);
+
+ _.forEach(this.groupMembersSelections, (selections, i) => {
+ selections.push(new SelectOption(false, '', ''));
+ this.groupMembersSelections[i] = [...selections];
+ });
+
+ const disks = _.map(
+ this.targetForm.getValue('disks'),
+ (disk) => new SelectOption(false, disk, '')
+ );
+ this.imagesInitiatorSelections.push(disks);
+
+ return fg;
+ }
+
+ setAuthValidator(fg: CdFormGroup) {
+ CdValidators.validateIf(
+ fg.get('user'),
+ () => fg.getValue('password') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
+ [Validators.required],
+ [Validators.pattern(this.USER_REGEX)],
+ [fg.get('password'), fg.get('mutual_user'), fg.get('mutual_password')]
+ );
+
+ CdValidators.validateIf(
+ fg.get('password'),
+ () => fg.getValue('user') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
+ [Validators.required],
+ [Validators.pattern(this.PASSWORD_REGEX)],
+ [fg.get('user'), fg.get('mutual_user'), fg.get('mutual_password')]
+ );
+
+ CdValidators.validateIf(
+ fg.get('mutual_user'),
+ () => fg.getValue('mutual_password'),
+ [Validators.required],
+ [Validators.pattern(this.USER_REGEX)],
+ [fg.get('user'), fg.get('password'), fg.get('mutual_password')]
+ );
+
+ CdValidators.validateIf(
+ fg.get('mutual_password'),
+ () => fg.getValue('mutual_user'),
+ [Validators.required],
+ [Validators.pattern(this.PASSWORD_REGEX)],
+ [fg.get('user'), fg.get('password'), fg.get('mutual_user')]
+ );
+ }
+
+ removeInitiator(index: number) {
+ const removed = this.initiators.value[index];
+
+ this.initiators.removeAt(index);
+
+ _.forEach(this.groupMembersSelections, (selections, i) => {
+ selections.splice(index, 1);
+ this.groupMembersSelections[i] = [...selections];
+ });
+
+ this.groups.controls.forEach((element) => {
+ const newMembers = element.value.members.filter(
+ (item: string) => item !== removed.client_iqn
+ );
+ element.get('members').setValue(newMembers);
+ });
+
+ this.imagesInitiatorSelections.splice(index, 1);
+ }
+
+ updatedInitiatorSelector() {
+ // Validate all client_iqn
+ this.initiators.controls.forEach((control) => {
+ control.get('client_iqn').updateValueAndValidity({ emitEvent: false });
+ });
+
+ // Update Group Initiator Selector
+ _.forEach(this.groupMembersSelections, (group, group_index) => {
+ _.forEach(group, (elem, index) => {
+ const oldName = elem.name;
+ elem.name = this.initiators.controls[index].value.client_iqn;
+
+ this.groups.controls.forEach((element) => {
+ const members = element.value.members;
+ const i = members.indexOf(oldName);
+
+ if (i !== -1) {
+ members[i] = elem.name;
+ }
+ element.get('members').setValue(members);
+ });
+ });
+ this.groupMembersSelections[group_index] = [...this.groupMembersSelections[group_index]];
+ });
+ }
+
+ removeInitiatorImage(initiator: any, lun_index: number, initiator_index: number, image: string) {
+ const luns = initiator.getValue('luns');
+ luns.splice(lun_index, 1);
+ initiator.patchValue({ luns: luns });
+
+ this.imagesInitiatorSelections[initiator_index].forEach((value: Record<string, any>) => {
+ if (value.name === image) {
+ value.selected = false;
+ }
+ });
+
+ return false;
+ }
+
+ // Groups
+ get groups() {
+ return this.targetForm.get('groups') as FormArray;
+ }
+
+ addGroup() {
+ const fg = new CdFormGroup({
+ group_id: new FormControl('', { validators: [Validators.required] }),
+ members: new FormControl([]),
+ disks: new FormControl([])
+ });
+
+ this.groups.push(fg);
+
+ const disks = _.map(
+ this.targetForm.getValue('disks'),
+ (disk) => new SelectOption(false, disk, '')
+ );
+ this.groupDiskSelections.push(disks);
+
+ const initiators = _.map(
+ this.initiators.value,
+ (initiator) => new SelectOption(false, initiator.client_iqn, '', !initiator.cdIsInGroup)
+ );
+ this.groupMembersSelections.push(initiators);
+
+ return fg;
+ }
+
+ removeGroup(index: number) {
+ // Remove group and disk selections
+ this.groups.removeAt(index);
+
+ // Free initiator from group
+ const selectedMembers = this.groupMembersSelections[index].filter((value) => value.selected);
+ selectedMembers.forEach((selection) => {
+ selection.selected = false;
+ this.onGroupMemberSelection({ option: selection }, index);
+ });
+
+ this.groupMembersSelections.splice(index, 1);
+ this.groupDiskSelections.splice(index, 1);
+ }
+
+ onGroupMemberSelection($event: any, group_index: number) {
+ const option = $event.option;
+
+ let luns: string[] = [];
+ if (!option.selected) {
+ const selectedDisks = this.groupDiskSelections[group_index].filter((value) => value.selected);
+ luns = selectedDisks.map((value) => value.name);
+ }
+
+ this.initiators.controls.forEach((element, index) => {
+ if (element.value.client_iqn === option.name) {
+ element.patchValue({ luns: luns });
+ element.get('cdIsInGroup').setValue(option.selected);
+
+ // Members can only be at one group at a time, so when a member is selected
+ // in one group we need to disable its selection in other groups
+ _.forEach(this.groupMembersSelections, (group) => {
+ group[index].enabled = !option.selected;
+ });
+
+ this.imagesInitiatorSelections[index].forEach((image) => {
+ image.selected = luns.includes(image.name);
+ });
+ }
+ });
+ }
+
+ removeGroupInitiator(group: CdFormGroup, member_index: number, group_index: number) {
+ const name = group.getValue('members')[member_index];
+ group.getValue('members').splice(member_index, 1);
+
+ this.onGroupMemberSelection({ option: new SelectOption(false, name, '') }, group_index);
+ }
+
+ removeGroupDisk(group: CdFormGroup, disk_index: number, group_index: number) {
+ const name = group.getValue('disks')[disk_index];
+ group.getValue('disks').splice(disk_index, 1);
+
+ this.groupDiskSelections[group_index].forEach((value) => {
+ if (value.name === name) {
+ value.selected = false;
+ }
+ });
+ this.groupDiskSelections[group_index] = [...this.groupDiskSelections[group_index]];
+ }
+
+ submit() {
+ const formValue = _.cloneDeep(this.targetForm.value);
+
+ const request: Record<string, any> = {
+ target_iqn: this.targetForm.getValue('target_iqn'),
+ target_controls: this.targetForm.getValue('target_controls'),
+ acl_enabled: this.targetForm.getValue('acl_enabled'),
+ portals: [],
+ disks: [],
+ clients: [],
+ groups: []
+ };
+
+ // Target level authentication was introduced in ceph-iscsi config v11
+ if (this.cephIscsiConfigVersion > 10) {
+ const targetAuth: CdFormGroup = this.targetForm.get('auth') as CdFormGroup;
+ if (!targetAuth.getValue('user')) {
+ targetAuth.get('user').setValue('');
+ }
+ if (!targetAuth.getValue('password')) {
+ targetAuth.get('password').setValue('');
+ }
+ if (!targetAuth.getValue('mutual_user')) {
+ targetAuth.get('mutual_user').setValue('');
+ }
+ if (!targetAuth.getValue('mutual_password')) {
+ targetAuth.get('mutual_password').setValue('');
+ }
+ const acl_enabled = this.targetForm.getValue('acl_enabled');
+ request['auth'] = {
+ user: acl_enabled ? '' : targetAuth.getValue('user'),
+ password: acl_enabled ? '' : targetAuth.getValue('password'),
+ mutual_user: acl_enabled ? '' : targetAuth.getValue('mutual_user'),
+ mutual_password: acl_enabled ? '' : targetAuth.getValue('mutual_password')
+ };
+ }
+
+ // Disks
+ formValue.disks.forEach((disk: string) => {
+ const imageSplit = disk.split('/');
+ const backstore = this.imagesSettings[disk].backstore;
+ request.disks.push({
+ pool: imageSplit[0],
+ image: imageSplit[1],
+ backstore: backstore,
+ controls: this.imagesSettings[disk][backstore],
+ lun: this.imagesSettings[disk]['lun'],
+ wwn: this.imagesSettings[disk]['wwn']
+ });
+ });
+
+ // Portals
+ formValue.portals.forEach((portal: string) => {
+ const index = portal.indexOf(':');
+ request.portals.push({
+ host: portal.substring(0, index),
+ ip: portal.substring(index + 1)
+ });
+ });
+
+ // Clients
+ if (request.acl_enabled) {
+ formValue.initiators.forEach((initiator: Record<string, any>) => {
+ if (!initiator.auth.user) {
+ initiator.auth.user = '';
+ }
+ if (!initiator.auth.password) {
+ initiator.auth.password = '';
+ }
+ if (!initiator.auth.mutual_user) {
+ initiator.auth.mutual_user = '';
+ }
+ if (!initiator.auth.mutual_password) {
+ initiator.auth.mutual_password = '';
+ }
+ delete initiator.cdIsInGroup;
+
+ const newLuns: any[] = [];
+ initiator.luns.forEach((lun: string) => {
+ const imageSplit = lun.split('/');
+ newLuns.push({
+ pool: imageSplit[0],
+ image: imageSplit[1]
+ });
+ });
+
+ initiator.luns = newLuns;
+ });
+ request.clients = formValue.initiators;
+ }
+
+ // Groups
+ if (request.acl_enabled) {
+ formValue.groups.forEach((group: Record<string, any>) => {
+ const newDisks: any[] = [];
+ group.disks.forEach((disk: string) => {
+ const imageSplit = disk.split('/');
+ newDisks.push({
+ pool: imageSplit[0],
+ image: imageSplit[1]
+ });
+ });
+
+ group.disks = newDisks;
+ });
+ request.groups = formValue.groups;
+ }
+
+ let wrapTask;
+ if (this.isEdit) {
+ request['new_target_iqn'] = request.target_iqn;
+ request.target_iqn = this.target_iqn;
+ wrapTask = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('iscsi/target/edit', {
+ target_iqn: request.target_iqn
+ }),
+ call: this.iscsiService.updateTarget(this.target_iqn, request)
+ });
+ } else {
+ wrapTask = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('iscsi/target/create', {
+ target_iqn: request.target_iqn
+ }),
+ call: this.iscsiService.createTarget(request)
+ });
+ }
+
+ wrapTask.subscribe({
+ error: () => {
+ this.targetForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => this.router.navigate(['/block/iscsi/targets'])
+ });
+ }
+
+ targetSettingsModal() {
+ const initialState = {
+ target_controls: this.targetForm.get('target_controls'),
+ target_default_controls: this.target_default_controls,
+ target_controls_limits: this.target_controls_limits
+ };
+
+ this.modalRef = this.modalService.show(IscsiTargetIqnSettingsModalComponent, initialState);
+ }
+
+ imageSettingsModal(image: string) {
+ const initialState = {
+ imagesSettings: this.imagesSettings,
+ image: image,
+ api_version: this.api_version,
+ disk_default_controls: this.disk_default_controls,
+ disk_controls_limits: this.disk_controls_limits,
+ backstores: this.getValidBackstores(this.getImageById(image)),
+ control: this.targetForm.get('disks')
+ };
+
+ this.modalRef = this.modalService.show(IscsiTargetImageSettingsModalComponent, initialState);
+ }
+
+ validFeatures(image: Record<string, any>, backstore: string) {
+ const imageFeatures = image.features;
+ const requiredFeatures = this.required_rbd_features[backstore];
+ const unsupportedFeatures = this.unsupported_rbd_features[backstore];
+ // tslint:disable-next-line:no-bitwise
+ const validRequiredFeatures = (imageFeatures & requiredFeatures) === requiredFeatures;
+ // tslint:disable-next-line:no-bitwise
+ const validSupportedFeatures = (imageFeatures & unsupportedFeatures) === 0;
+ return validRequiredFeatures && validSupportedFeatures;
+ }
+
+ getImageById(imageId: string) {
+ return this.imagesAll.find((image) => imageId === `${image.pool_name}/${image.name}`);
+ }
+
+ getValidBackstores(image: object) {
+ return this.backstores.filter((backstore) => this.validFeatures(image, backstore));
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html
new file mode 100644
index 000000000..2452b0bc5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html
@@ -0,0 +1,92 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title">
+ <ng-container i18n>Configure</ng-container>&nbsp;
+ <small>{{ image }}</small>
+ </ng-container>
+
+ <ng-container class="modal-content">
+ <form name="settingsForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="settingsForm"
+ novalidate>
+ <div class="modal-body">
+ <p class="alert-warning"
+ i18n>Changing these parameters from their default values is usually not necessary.</p>
+
+ <span *ngIf="api_version >= 1">
+ <legend class="cd-header"
+ i18n>Identifier</legend>
+ <!-- LUN -->
+ <div class="form-group row">
+ <div class="col-sm-12">
+ <label class="col-form-label required"
+ for="lun"
+ i18n>lun</label>
+ <input type="number"
+ class="form-control"
+ id="lun"
+ name="lun"
+ formControlName="lun">
+ <span class="invalid-feedback"
+ *ngIf="settingsForm.showError('lun', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- WWN -->
+ <div class="form-group row">
+ <div class="col-sm-12">
+ <label class="col-form-label"
+ for="wwn"
+ i18n>wwn</label>
+ <input type="text"
+ class="form-control"
+ id="wwn"
+ name="wwn"
+ formControlName="wwn">
+ </div>
+ </div>
+ </span>
+
+ <legend class="cd-header"
+ i18n>Settings</legend>
+
+ <!-- BACKSTORE -->
+ <div class="form-group row">
+ <div class="col-sm-12">
+ <label class="col-form-label"
+ i18n>Backstore</label>
+ <select id="backstore"
+ name="backstore"
+ class="form-control"
+ formControlName="backstore">
+ <option *ngFor="let bs of backstores"
+ [value]="bs">{{ bs | iscsiBackstore }}</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- CONTROLS -->
+ <ng-container *ngFor="let bs of backstores">
+ <ng-container *ngIf="settingsForm.value['backstore'] === bs">
+ <div class="form-group row"
+ *ngFor="let setting of disk_default_controls[bs] | keyvalue">
+ <div class="col-sm-12">
+ <cd-iscsi-setting [settingsForm]="settingsForm"
+ [formDir]="formDir"
+ [setting]="setting.key"
+ [limits]="getDiskControlLimits(bs, setting.key)"></cd-iscsi-setting>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="save()"
+ [form]="settingsForm"
+ [submitText]="actionLabels.UPDATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts
new file mode 100644
index 000000000..cb37b2ffe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts
@@ -0,0 +1,98 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiSettingComponent } from '../iscsi-setting/iscsi-setting.component';
+import { IscsiTargetImageSettingsModalComponent } from './iscsi-target-image-settings-modal.component';
+
+describe('IscsiTargetImageSettingsModalComponent', () => {
+ let component: IscsiTargetImageSettingsModalComponent;
+ let fixture: ComponentFixture<IscsiTargetImageSettingsModalComponent>;
+
+ configureTestBed({
+ declarations: [IscsiTargetImageSettingsModalComponent, IscsiSettingComponent],
+ imports: [SharedModule, ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTargetImageSettingsModalComponent);
+ component = fixture.componentInstance;
+
+ component.imagesSettings = { 'rbd/disk_1': { backstore: 'backstore:1', 'backstore:1': {} } };
+ component.image = 'rbd/disk_1';
+ component.disk_default_controls = {
+ 'backstore:1': {
+ foo: 1,
+ bar: 2
+ },
+ 'backstore:2': {
+ baz: 3
+ }
+ };
+ component.disk_controls_limits = {
+ 'backstore:1': {
+ foo: {
+ min: 1,
+ max: 512,
+ type: 'int'
+ },
+ bar: {
+ min: 1,
+ max: 512,
+ type: 'int'
+ }
+ },
+ 'backstore:2': {
+ baz: {
+ min: 1,
+ max: 512,
+ type: 'int'
+ }
+ }
+ };
+ component.backstores = ['backstore:1', 'backstore:2'];
+ component.control = new FormControl();
+
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should fill the form', () => {
+ expect(component.settingsForm.value).toEqual({
+ lun: null,
+ wwn: null,
+ backstore: 'backstore:1',
+ foo: null,
+ bar: null,
+ baz: null
+ });
+ });
+
+ it('should save changes to imagesSettings', () => {
+ component.settingsForm.controls['foo'].setValue(1234);
+ expect(component.imagesSettings).toEqual({
+ 'rbd/disk_1': { backstore: 'backstore:1', 'backstore:1': {} }
+ });
+ component.save();
+ expect(component.imagesSettings).toEqual({
+ 'rbd/disk_1': {
+ lun: null,
+ wwn: null,
+ backstore: 'backstore:1',
+ 'backstore:1': {
+ foo: 1234
+ }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts
new file mode 100644
index 000000000..e9c9c7d90
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts
@@ -0,0 +1,87 @@
+import { Component, OnInit } from '@angular/core';
+import { AbstractControl, FormControl } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+@Component({
+ selector: 'cd-iscsi-target-image-settings-modal',
+ templateUrl: './iscsi-target-image-settings-modal.component.html',
+ styleUrls: ['./iscsi-target-image-settings-modal.component.scss']
+})
+export class IscsiTargetImageSettingsModalComponent implements OnInit {
+ image: string;
+ imagesSettings: any;
+ api_version: number;
+ disk_default_controls: any;
+ disk_controls_limits: any;
+ backstores: any;
+ control: AbstractControl;
+
+ settingsForm: CdFormGroup;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public iscsiService: IscsiService,
+ public actionLabels: ActionLabelsI18n
+ ) {}
+
+ ngOnInit() {
+ const fg: Record<string, FormControl> = {
+ backstore: new FormControl(this.imagesSettings[this.image]['backstore']),
+ lun: new FormControl(this.imagesSettings[this.image]['lun']),
+ wwn: new FormControl(this.imagesSettings[this.image]['wwn'])
+ };
+ _.forEach(this.backstores, (backstore) => {
+ const model = this.imagesSettings[this.image][backstore] || {};
+ _.forIn(this.disk_default_controls[backstore], (_value, key) => {
+ fg[key] = new FormControl(model[key]);
+ });
+ });
+
+ this.settingsForm = new CdFormGroup(fg);
+ }
+
+ getDiskControlLimits(backstore: string, setting: string) {
+ if (this.disk_controls_limits) {
+ return this.disk_controls_limits[backstore][setting];
+ }
+ // backward compatibility
+ return { type: 'int' };
+ }
+
+ save() {
+ const backstore = this.settingsForm.controls['backstore'].value;
+ const lun = this.settingsForm.controls['lun'].value;
+ const wwn = this.settingsForm.controls['wwn'].value;
+ const settings = {};
+ _.forIn(this.settingsForm.controls, (control, key) => {
+ if (
+ !(control.value === '' || control.value === null) &&
+ key in this.disk_default_controls[this.settingsForm.value['backstore']]
+ ) {
+ settings[key] = control.value;
+ // If one setting belongs to multiple backstores, we have to update it in all backstores
+ _.forEach(this.backstores, (currentBackstore) => {
+ if (currentBackstore !== backstore) {
+ const model = this.imagesSettings[this.image][currentBackstore] || {};
+ if (key in model) {
+ this.imagesSettings[this.image][currentBackstore][key] = control.value;
+ }
+ }
+ });
+ }
+ });
+ this.imagesSettings[this.image]['backstore'] = backstore;
+ this.imagesSettings[this.image]['lun'] = lun;
+ this.imagesSettings[this.image]['wwn'] = wwn;
+ this.imagesSettings[this.image][backstore] = settings;
+ this.imagesSettings = { ...this.imagesSettings };
+ this.control.updateValueAndValidity({ emitEvent: false });
+ this.activeModal.close();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html
new file mode 100644
index 000000000..a5d1269f6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html
@@ -0,0 +1,32 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>Advanced Settings</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="settingsForm"
+ #formDir="ngForm"
+ [formGroup]="settingsForm"
+ novalidate>
+ <div class="modal-body">
+ <p class="alert-warning"
+ i18n>Changing these parameters from their default values is usually not necessary.</p>
+
+ <div class="form-group row"
+ *ngFor="let setting of settingsForm.controls | keyvalue">
+ <div class="col-sm-12">
+ <cd-iscsi-setting [settingsForm]="settingsForm"
+ [formDir]="formDir"
+ [setting]="setting.key"
+ [limits]="getTargetControlLimits(setting.key)"></cd-iscsi-setting>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="save()"
+ [form]="settingsForm"
+ [submitText]="actionLabels.UPDATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts
new file mode 100644
index 000000000..dda1be3c1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts
@@ -0,0 +1,71 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiSettingComponent } from '../iscsi-setting/iscsi-setting.component';
+import { IscsiTargetIqnSettingsModalComponent } from './iscsi-target-iqn-settings-modal.component';
+
+describe('IscsiTargetIqnSettingsModalComponent', () => {
+ let component: IscsiTargetIqnSettingsModalComponent;
+ let fixture: ComponentFixture<IscsiTargetIqnSettingsModalComponent>;
+
+ configureTestBed({
+ declarations: [IscsiTargetIqnSettingsModalComponent, IscsiSettingComponent],
+ imports: [SharedModule, ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTargetIqnSettingsModalComponent);
+ component = fixture.componentInstance;
+ component.target_controls = new FormControl({});
+ component.target_default_controls = {
+ cmdsn_depth: 1,
+ dataout_timeout: 2,
+ first_burst_length: true
+ };
+ component.target_controls_limits = {
+ cmdsn_depth: {
+ min: 1,
+ max: 512,
+ type: 'int'
+ },
+ dataout_timeout: {
+ min: 2,
+ max: 60,
+ type: 'int'
+ },
+ first_burst_length: {
+ max: 16777215,
+ min: 512,
+ type: 'int'
+ }
+ };
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should fill the settingsForm', () => {
+ expect(component.settingsForm.value).toEqual({
+ cmdsn_depth: null,
+ dataout_timeout: null,
+ first_burst_length: null
+ });
+ });
+
+ it('should save changes to target_controls', () => {
+ component.settingsForm.patchValue({ dataout_timeout: 1234 });
+ expect(component.target_controls.value).toEqual({});
+ component.save();
+ expect(component.target_controls.value).toEqual({ dataout_timeout: 1234 });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts
new file mode 100644
index 000000000..36fdb9026
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts
@@ -0,0 +1,60 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+@Component({
+ selector: 'cd-iscsi-target-iqn-settings-modal',
+ templateUrl: './iscsi-target-iqn-settings-modal.component.html',
+ styleUrls: ['./iscsi-target-iqn-settings-modal.component.scss']
+})
+export class IscsiTargetIqnSettingsModalComponent implements OnInit {
+ target_controls: FormControl;
+ target_default_controls: any;
+ target_controls_limits: any;
+
+ settingsForm: CdFormGroup;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public iscsiService: IscsiService,
+ public actionLabels: ActionLabelsI18n
+ ) {}
+
+ ngOnInit() {
+ const fg: Record<string, FormControl> = {};
+ _.forIn(this.target_default_controls, (_value, key) => {
+ fg[key] = new FormControl(this.target_controls.value[key]);
+ });
+
+ this.settingsForm = new CdFormGroup(fg);
+ }
+
+ save() {
+ const settings = {};
+ _.forIn(this.settingsForm.controls, (control, key) => {
+ if (!(control.value === '' || control.value === null)) {
+ settings[key] = control.value;
+ }
+ });
+
+ this.target_controls.setValue(settings);
+ this.activeModal.close();
+ }
+
+ getTargetControlLimits(setting: string) {
+ if (this.target_controls_limits) {
+ return this.target_controls_limits[setting];
+ }
+ // backward compatibility
+ if (['Yes', 'No'].includes(this.target_default_controls[setting])) {
+ return { type: 'bool' };
+ }
+ return { type: 'int' };
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html
new file mode 100644
index 000000000..f6ac54538
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html
@@ -0,0 +1,53 @@
+<cd-iscsi-tabs></cd-iscsi-tabs>
+
+<cd-alert-panel type="info"
+ *ngIf="available === false"
+ title="iSCSI Targets not available"
+ i18n-title>
+ <ng-container i18n>Please consult the <cd-doc section="iscsi"></cd-doc> on
+ how to configure and enable the iSCSI Targets management functionality.</ng-container>
+
+ <ng-container *ngIf="status">
+ <br>
+ <span i18n>Available information:</span>
+ <pre>{{ status }}</pre>
+ </ng-container>
+</cd-alert-panel>
+
+<cd-table #table
+ *ngIf="available === true"
+ [data]="targets"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="target_iqn"
+ forceIdentifier="true"
+ selectionType="single"
+ [hasDetails]="true"
+ [autoReload]="false"
+ [status]="tableStatus"
+ (fetchData)="getTargets()"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions class="btn-group"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+
+ <button class="btn btn-light"
+ type="button"
+ (click)="configureDiscoveryAuth()">
+ <i [ngClass]="[icons.key]"
+ aria-hidden="true">
+ </i>
+ <ng-container i18n>Discovery authentication</ng-container>
+ </button>
+ </div>
+
+ <cd-iscsi-target-details cdTableDetail
+ *ngIf="expandedRow"
+ [cephIscsiConfigVersion]="cephIscsiConfigVersion"
+ [selection]="expandedRow"
+ [settings]="settings"></cd-iscsi-target-details>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts
new file mode 100644
index 000000000..51998cf0b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts
@@ -0,0 +1,309 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { TreeModule } from '@circlon/angular-tree-component';
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, expectItemTasks, PermissionHelper } from '~/testing/unit-test-helper';
+import { IscsiTabsComponent } from '../iscsi-tabs/iscsi-tabs.component';
+import { IscsiTargetDetailsComponent } from '../iscsi-target-details/iscsi-target-details.component';
+import { IscsiTargetListComponent } from './iscsi-target-list.component';
+
+describe('IscsiTargetListComponent', () => {
+ let component: IscsiTargetListComponent;
+ let fixture: ComponentFixture<IscsiTargetListComponent>;
+ let summaryService: SummaryService;
+ let iscsiService: IscsiService;
+
+ const refresh = (data: any) => {
+ summaryService['summaryDataSource'].next(data);
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ TreeModule,
+ ToastrModule.forRoot(),
+ NgbNavModule
+ ],
+ declarations: [IscsiTargetListComponent, IscsiTabsComponent, IscsiTargetDetailsComponent],
+ providers: [TaskListService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiTargetListComponent);
+ component = fixture.componentInstance;
+ summaryService = TestBed.inject(SummaryService);
+ iscsiService = TestBed.inject(IscsiService);
+
+ // this is needed because summaryService isn't being reset after each test.
+ summaryService['summaryDataSource'] = new BehaviorSubject(null);
+ summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable();
+
+ spyOn(iscsiService, 'status').and.returnValue(of({ available: true }));
+ spyOn(iscsiService, 'version').and.returnValue(of({ ceph_iscsi_config_version: 11 }));
+ spyOn(component, 'setTableRefreshTimeout').and.stub();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('after ngOnInit', () => {
+ beforeEach(() => {
+ spyOn(iscsiService, 'listTargets').and.callThrough();
+ fixture.detectChanges();
+ });
+
+ it('should load targets on init', () => {
+ refresh({});
+ expect(iscsiService.status).toHaveBeenCalled();
+ expect(iscsiService.listTargets).toHaveBeenCalled();
+ });
+
+ it('should not load targets on init because no data', () => {
+ refresh(undefined);
+ expect(iscsiService.listTargets).not.toHaveBeenCalled();
+ });
+
+ it('should call error function on init when summary service fails', () => {
+ spyOn(component.table, 'reset');
+ summaryService['summaryDataSource'].error(undefined);
+ expect(component.table.reset).toHaveBeenCalled();
+ });
+
+ it('should call settings on the getTargets methods', () => {
+ spyOn(iscsiService, 'settings').and.callThrough();
+ component.getTargets();
+ expect(iscsiService.settings).toHaveBeenCalled();
+ });
+ });
+
+ describe('handling of executing tasks', () => {
+ let targets: any[];
+
+ const addTarget = (name: string) => {
+ const model: any = {
+ target_iqn: name,
+ portals: [{ host: 'node1', ip: '192.168.100.201' }],
+ disks: [{ pool: 'rbd', image: 'disk_1', controls: {} }],
+ clients: [
+ {
+ client_iqn: 'iqn.1994-05.com.redhat:rh7-client',
+ luns: [{ pool: 'rbd', image: 'disk_1' }],
+ auth: {
+ user: 'myiscsiusername',
+ password: 'myiscsipassword',
+ mutual_user: null,
+ mutual_password: null
+ }
+ }
+ ],
+ groups: [],
+ target_controls: {}
+ };
+ targets.push(model);
+ };
+
+ const addTask = (name: string, target_iqn: string) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ switch (task.name) {
+ case 'iscsi/target/create':
+ task.metadata = {
+ target_iqn: target_iqn
+ };
+ break;
+ case 'iscsi/target/delete':
+ task.metadata = {
+ target_iqn: target_iqn
+ };
+ break;
+ default:
+ task.metadata = {
+ target_iqn: target_iqn
+ };
+ break;
+ }
+ summaryService.addRunningTask(task);
+ };
+
+ beforeEach(() => {
+ targets = [];
+ addTarget('iqn.a');
+ addTarget('iqn.b');
+ addTarget('iqn.c');
+
+ component.targets = targets;
+ refresh({ executing_tasks: [], finished_tasks: [] });
+ spyOn(iscsiService, 'listTargets').and.callFake(() => of(targets));
+ fixture.detectChanges();
+ });
+
+ it('should gets all targets without tasks', () => {
+ expect(component.targets.length).toBe(3);
+ expect(component.targets.every((target) => !target.cdExecuting)).toBeTruthy();
+ });
+
+ it('should add a new target from a task', () => {
+ addTask('iscsi/target/create', 'iqn.d');
+ expect(component.targets.length).toBe(4);
+ expectItemTasks(component.targets[0], undefined);
+ expectItemTasks(component.targets[1], undefined);
+ expectItemTasks(component.targets[2], undefined);
+ expectItemTasks(component.targets[3], 'Creating');
+ });
+
+ it('should show when an existing target is being modified', () => {
+ addTask('iscsi/target/delete', 'iqn.b');
+ expect(component.targets.length).toBe(3);
+ expectItemTasks(component.targets[1], 'Deleting');
+ });
+ });
+
+ describe('handling of actions', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ let action: CdTableAction;
+
+ const getAction = (name: string): CdTableAction => {
+ return component.tableActions.find((tableAction) => tableAction.name === name);
+ };
+
+ describe('edit', () => {
+ beforeEach(() => {
+ action = getAction('Edit');
+ });
+
+ it('should be disabled if no gateways', () => {
+ component.selection.selected = [
+ {
+ id: '-1'
+ }
+ ];
+ expect(action.disable(undefined)).toBe('Unavailable gateway(s)');
+ });
+
+ it('should be enabled if active sessions', () => {
+ component.selection.selected = [
+ {
+ id: '-1',
+ info: {
+ num_sessions: 1
+ }
+ }
+ ];
+ expect(action.disable(undefined)).toBeFalsy();
+ });
+
+ it('should be enabled if no active sessions', () => {
+ component.selection.selected = [
+ {
+ id: '-1',
+ info: {
+ num_sessions: 0
+ }
+ }
+ ];
+ expect(action.disable(undefined)).toBeFalsy();
+ });
+ });
+
+ describe('delete', () => {
+ beforeEach(() => {
+ action = getAction('Delete');
+ });
+
+ it('should be disabled if no gateways', () => {
+ component.selection.selected = [
+ {
+ id: '-1'
+ }
+ ];
+ expect(action.disable(undefined)).toBe('Unavailable gateway(s)');
+ });
+
+ it('should be disabled if active sessions', () => {
+ component.selection.selected = [
+ {
+ id: '-1',
+ info: {
+ num_sessions: 1
+ }
+ }
+ ];
+ expect(action.disable(undefined)).toBe('Target has active sessions');
+ });
+
+ it('should be enabled if no active sessions', () => {
+ component.selection.selected = [
+ {
+ id: '-1',
+ info: {
+ num_sessions: 0
+ }
+ }
+ ];
+ expect(action.disable(undefined)).toBeFalsy();
+ });
+ });
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Edit', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Delete', single: 'Delete', no: 'Create' }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Delete'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts
new file mode 100644
index 000000000..d0eed6a72
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts
@@ -0,0 +1,242 @@
+import { Component, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Subscription } from 'rxjs';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } 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';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+import { Task } from '~/app/shared/models/task';
+import { JoinPipe } from '~/app/shared/pipes/join.pipe';
+import { NotAvailablePipe } from '~/app/shared/pipes/not-available.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { IscsiTargetDiscoveryModalComponent } from '../iscsi-target-discovery-modal/iscsi-target-discovery-modal.component';
+
+@Component({
+ selector: 'cd-iscsi-target-list',
+ templateUrl: './iscsi-target-list.component.html',
+ styleUrls: ['./iscsi-target-list.component.scss'],
+ providers: [TaskListService]
+})
+export class IscsiTargetListComponent extends ListWithDetails implements OnInit, OnDestroy {
+ @ViewChild(TableComponent)
+ table: TableComponent;
+
+ available: boolean = undefined;
+ columns: CdTableColumn[];
+ modalRef: NgbModalRef;
+ permission: Permission;
+ selection = new CdTableSelection();
+ cephIscsiConfigVersion: number;
+ settings: any;
+ status: string;
+ summaryDataSubscription: Subscription;
+ tableActions: CdTableAction[];
+ targets: any[] = [];
+ icons = Icons;
+
+ builders = {
+ 'iscsi/target/create': (metadata: object) => {
+ return {
+ target_iqn: metadata['target_iqn']
+ };
+ }
+ };
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private iscsiService: IscsiService,
+ private joinPipe: JoinPipe,
+ private taskListService: TaskListService,
+ private notAvailablePipe: NotAvailablePipe,
+ private modalService: ModalService,
+ private taskWrapper: TaskWrapperService,
+ public actionLabels: ActionLabelsI18n,
+ protected ngZone: NgZone
+ ) {
+ super(ngZone);
+ this.permission = this.authStorageService.getPermissions().iscsi;
+
+ this.tableActions = [
+ {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => '/block/iscsi/targets/create',
+ name: this.actionLabels.CREATE
+ },
+ {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () => `/block/iscsi/targets/edit/${this.selection.first().target_iqn}`,
+ name: this.actionLabels.EDIT,
+ disable: () => this.getEditDisableDesc()
+ },
+ {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteIscsiTargetModal(),
+ name: this.actionLabels.DELETE,
+ disable: () => this.getDeleteDisableDesc()
+ }
+ ];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Target`,
+ prop: 'target_iqn',
+ flexGrow: 2,
+ cellTransformation: CellTemplate.executing
+ },
+ {
+ name: $localize`Portals`,
+ prop: 'cdPortals',
+ pipe: this.joinPipe,
+ flexGrow: 2
+ },
+ {
+ name: $localize`Images`,
+ prop: 'cdImages',
+ pipe: this.joinPipe,
+ flexGrow: 2
+ },
+ {
+ name: $localize`# Sessions`,
+ prop: 'info.num_sessions',
+ pipe: this.notAvailablePipe,
+ flexGrow: 1
+ }
+ ];
+
+ this.iscsiService.status().subscribe((result: any) => {
+ this.available = result.available;
+
+ if (!result.available) {
+ this.status = result.message;
+ }
+ });
+ }
+
+ getTargets() {
+ if (this.available) {
+ this.setTableRefreshTimeout();
+ this.iscsiService.version().subscribe((res: any) => {
+ this.cephIscsiConfigVersion = res['ceph_iscsi_config_version'];
+ });
+ this.taskListService.init(
+ () => this.iscsiService.listTargets(),
+ (resp) => this.prepareResponse(resp),
+ (targets) => (this.targets = targets),
+ () => this.onFetchError(),
+ this.taskFilter,
+ this.itemFilter,
+ this.builders
+ );
+
+ this.iscsiService.settings().subscribe((settings: any) => {
+ this.settings = settings;
+ });
+ }
+ }
+
+ ngOnDestroy() {
+ if (this.summaryDataSubscription) {
+ this.summaryDataSubscription.unsubscribe();
+ }
+ }
+
+ getEditDisableDesc(): string | boolean {
+ const first = this.selection.first();
+
+ if (first && first?.cdExecuting) {
+ return first.cdExecuting;
+ }
+
+ if (first && _.isUndefined(first?.['info'])) {
+ return $localize`Unavailable gateway(s)`;
+ }
+
+ return !first;
+ }
+
+ getDeleteDisableDesc(): string | boolean {
+ const first = this.selection.first();
+
+ if (first?.cdExecuting) {
+ return first.cdExecuting;
+ }
+
+ if (first && _.isUndefined(first?.['info'])) {
+ return $localize`Unavailable gateway(s)`;
+ }
+
+ if (first && first?.['info']?.['num_sessions']) {
+ return $localize`Target has active sessions`;
+ }
+
+ return !first;
+ }
+
+ prepareResponse(resp: any): any[] {
+ resp.forEach((element: Record<string, any>) => {
+ element.cdPortals = element.portals.map(
+ (portal: Record<string, any>) => `${portal.host}:${portal.ip}`
+ );
+ element.cdImages = element.disks.map(
+ (disk: Record<string, any>) => `${disk.pool}/${disk.image}`
+ );
+ });
+
+ return resp;
+ }
+
+ onFetchError() {
+ this.table.reset(); // Disable loading indicator.
+ }
+
+ itemFilter(entry: Record<string, any>, task: Task) {
+ return entry.target_iqn === task.metadata['target_iqn'];
+ }
+
+ taskFilter(task: Task) {
+ return ['iscsi/target/create', 'iscsi/target/edit', 'iscsi/target/delete'].includes(task.name);
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteIscsiTargetModal() {
+ const target_iqn = this.selection.first().target_iqn;
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: $localize`iSCSI target`,
+ itemNames: [target_iqn],
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('iscsi/target/delete', {
+ target_iqn: target_iqn
+ }),
+ call: this.iscsiService.deleteTarget(target_iqn)
+ })
+ });
+ }
+
+ configureDiscoveryAuth() {
+ this.modalService.show(IscsiTargetDiscoveryModalComponent);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html
new file mode 100644
index 000000000..eccb79514
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html
@@ -0,0 +1,49 @@
+<cd-iscsi-tabs></cd-iscsi-tabs>
+
+<legend i18n>Gateways</legend>
+<cd-table [data]="gateways"
+ (fetchData)="refresh()"
+ [columns]="gatewaysColumns">
+</cd-table>
+
+<legend i18n>Images</legend>
+<cd-table [data]="images"
+ [columns]="imagesColumns">
+</cd-table>
+
+<ng-template #iscsiSparklineTpl
+ let-row="row"
+ let-value="value">
+ <span *ngIf="row.backstore === 'user:rbd'">
+ <cd-sparkline [data]="value"
+ [isBinary]="row.cdIsBinary"></cd-sparkline>
+ </span>
+ <span *ngIf="row.backstore !== 'user:rbd'"
+ class="text-muted">
+ n/a
+ </span>
+</ng-template>
+
+<ng-template #iscsiPerSecondTpl
+ let-row="row"
+ let-value="value">
+ <span *ngIf="row.backstore === 'user:rbd'">
+ {{ value }} /s
+ </span>
+ <span *ngIf="row.backstore !== 'user:rbd'"
+ class="text-muted">
+ n/a
+ </span>
+</ng-template>
+
+<ng-template #iscsiRelativeDateTpl
+ let-row="row"
+ let-value="value">
+ <span *ngIf="row.backstore === 'user:rbd'">
+ {{ value | relativeDate | notAvailable }}
+ </span>
+ <span *ngIf="row.backstore !== 'user:rbd'"
+ class="text-muted">
+ n/a
+ </span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.spec.ts
new file mode 100644
index 000000000..9e99bf9e6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.spec.ts
@@ -0,0 +1,83 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { of } from 'rxjs';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { CephShortVersionPipe } from '~/app/shared/pipes/ceph-short-version.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { IscsiBackstorePipe } from '~/app/shared/pipes/iscsi-backstore.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiComponent } from './iscsi.component';
+
+describe('IscsiComponent', () => {
+ let component: IscsiComponent;
+ let fixture: ComponentFixture<IscsiComponent>;
+ let iscsiService: IscsiService;
+ let tcmuiscsiData: Record<string, any>;
+
+ const fakeService = {
+ overview: () => {
+ return new Promise(function () {
+ return;
+ });
+ }
+ };
+
+ configureTestBed({
+ imports: [BrowserAnimationsModule, SharedModule],
+ declarations: [IscsiComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ CephShortVersionPipe,
+ DimlessPipe,
+ FormatterService,
+ IscsiBackstorePipe,
+ { provide: IscsiService, useValue: fakeService }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IscsiComponent);
+ component = fixture.componentInstance;
+ iscsiService = TestBed.inject(IscsiService);
+ fixture.detectChanges();
+ tcmuiscsiData = {
+ images: []
+ };
+ spyOn(iscsiService, 'overview').and.callFake(() => of(tcmuiscsiData));
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should refresh without stats available', () => {
+ tcmuiscsiData.images.push({});
+ component.refresh();
+ expect(component.images[0].cdIsBinary).toBe(true);
+ });
+
+ it('should refresh with stats', () => {
+ tcmuiscsiData.images.push({
+ stats_history: {
+ rd_bytes: [
+ [1540551220, 0.0],
+ [1540551225, 0.0],
+ [1540551230, 0.0]
+ ],
+ wr_bytes: [
+ [1540551220, 0.0],
+ [1540551225, 0.0],
+ [1540551230, 0.0]
+ ]
+ }
+ });
+ component.refresh();
+ expect(component.images[0].stats_history).toEqual({ rd_bytes: [0, 0, 0], wr_bytes: [0, 0, 0] });
+ expect(component.images[0].cdIsBinary).toBe(true);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.ts
new file mode 100644
index 000000000..89e4d7f34
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.ts
@@ -0,0 +1,117 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { IscsiService } from '~/app/shared/api/iscsi.service';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { IscsiBackstorePipe } from '~/app/shared/pipes/iscsi-backstore.pipe';
+
+@Component({
+ selector: 'cd-iscsi',
+ templateUrl: './iscsi.component.html',
+ styleUrls: ['./iscsi.component.scss']
+})
+export class IscsiComponent implements OnInit {
+ @ViewChild('iscsiSparklineTpl', { static: true })
+ iscsiSparklineTpl: TemplateRef<any>;
+ @ViewChild('iscsiPerSecondTpl', { static: true })
+ iscsiPerSecondTpl: TemplateRef<any>;
+ @ViewChild('iscsiRelativeDateTpl', { static: true })
+ iscsiRelativeDateTpl: TemplateRef<any>;
+
+ gateways: any[] = [];
+ gatewaysColumns: any;
+ images: any[] = [];
+ imagesColumns: any;
+
+ constructor(
+ private iscsiService: IscsiService,
+ private dimlessPipe: DimlessPipe,
+ private iscsiBackstorePipe: IscsiBackstorePipe
+ ) {}
+
+ ngOnInit() {
+ this.gatewaysColumns = [
+ {
+ name: $localize`Name`,
+ prop: 'name'
+ },
+ {
+ name: $localize`State`,
+ prop: 'state',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ up: { class: 'badge-success' },
+ down: { class: 'badge-danger' }
+ }
+ }
+ },
+ {
+ name: $localize`# Targets`,
+ prop: 'num_targets'
+ },
+ {
+ name: $localize`# Sessions`,
+ prop: 'num_sessions'
+ }
+ ];
+ this.imagesColumns = [
+ {
+ name: $localize`Pool`,
+ prop: 'pool'
+ },
+ {
+ name: $localize`Image`,
+ prop: 'image'
+ },
+ {
+ name: $localize`Backstore`,
+ prop: 'backstore',
+ pipe: this.iscsiBackstorePipe
+ },
+ {
+ name: $localize`Read Bytes`,
+ prop: 'stats_history.rd_bytes',
+ cellTemplate: this.iscsiSparklineTpl
+ },
+ {
+ name: $localize`Write Bytes`,
+ prop: 'stats_history.wr_bytes',
+ cellTemplate: this.iscsiSparklineTpl
+ },
+ {
+ name: $localize`Read Ops`,
+ prop: 'stats.rd',
+ pipe: this.dimlessPipe,
+ cellTemplate: this.iscsiPerSecondTpl
+ },
+ {
+ name: $localize`Write Ops`,
+ prop: 'stats.wr',
+ pipe: this.dimlessPipe,
+ cellTemplate: this.iscsiPerSecondTpl
+ },
+ {
+ name: $localize`A/O Since`,
+ prop: 'optimized_since',
+ cellTemplate: this.iscsiRelativeDateTpl
+ }
+ ];
+ }
+
+ refresh() {
+ this.iscsiService.overview().subscribe((overview: object) => {
+ this.gateways = overview['gateways'];
+ this.images = overview['images'];
+ this.images.map((image) => {
+ if (image.stats_history) {
+ image.stats_history.rd_bytes = image.stats_history.rd_bytes.map((i: any) => i[1]);
+ image.stats_history.wr_bytes = image.stats_history.wr_bytes.map((i: any) => i[1]);
+ }
+ image.cdIsBinary = true;
+ return image;
+ });
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html
new file mode 100755
index 000000000..a31ab933c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.html
@@ -0,0 +1,87 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n
+ class="modal-title">Create Bootstrap Token</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="createBootstrapForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="createBootstrapForm"
+ novalidate>
+ <div class="modal-body">
+ <p>
+ <ng-container i18n>To create a bootstrap token which can be imported
+ by a peer site cluster, provide the local site's name, select
+ which pools will have mirroring enabled, and click&nbsp;
+ <kbd>Generate</kbd>.</ng-container>
+ </p>
+
+ <div class="form-group">
+ <label class="col-form-label required"
+ for="siteName"
+ i18n>Site Name</label>
+ <input class="form-control"
+ type="text"
+ placeholder="Name..."
+ i18n-placeholder
+ id="siteName"
+ name="siteName"
+ formControlName="siteName"
+ autofocus>
+ <span *ngIf="createBootstrapForm.showError('siteName', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required.</span>
+ </div>
+
+ <div class="form-group"
+ formGroupName="pools">
+ <label class="col-form-label required"
+ for="pools"
+ i18n>Pools</label>
+ <div class="custom-control custom-checkbox"
+ *ngFor="let pool of pools">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="{{ pool.name }}"
+ name="{{ pool.name }}"
+ formControlName="{{ pool.name }}">
+ <label class="custom-control-label"
+ for="{{ pool.name }}">{{ pool.name }}</label>
+ </div>
+ <span *ngIf="createBootstrapForm.showError('pools', formDir, 'requirePool')"
+ class="invalid-feedback"
+ i18n>At least one pool is required.</span>
+ </div>
+
+ <cd-submit-button class="mb-4 float-right"
+ i18n
+ [form]="createBootstrapForm"
+ (submitAction)="generate()">Generate</cd-submit-button>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="token">
+ <span i18n>Token</span>
+ </label>
+ <textarea class="form-control resize-vertical"
+ placeholder="Generated token..."
+ i18n-placeholder
+ id="token"
+ formControlName="token"
+ readonly>
+ </textarea>
+ </div>
+ <cd-copy-2-clipboard-button class="float-right"
+ source="token">
+ </cd-copy-2-clipboard-button>
+ </div>
+
+ <div class="modal-footer">
+ <cd-back-button (backAction)="activeModal.close()"
+ name="Close"
+ i18n-name>
+ </cd-back-button>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss
new file mode 100644
index 000000000..8dc4d1c73
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.scss
@@ -0,0 +1,3 @@
+.form-group.ng-invalid .invalid-feedback {
+ display: block;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts
new file mode 100644
index 000000000..f8f634476
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.spec.ts
@@ -0,0 +1,113 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { BootstrapCreateModalComponent } from './bootstrap-create-modal.component';
+
+describe('BootstrapCreateModalComponent', () => {
+ let component: BootstrapCreateModalComponent;
+ let fixture: ComponentFixture<BootstrapCreateModalComponent>;
+ let notificationService: NotificationService;
+ let rbdMirroringService: RbdMirroringService;
+ let formHelper: FormHelper;
+
+ configureTestBed({
+ declarations: [BootstrapCreateModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BootstrapCreateModalComponent);
+ component = fixture.componentInstance;
+ component.siteName = 'site-A';
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+
+ rbdMirroringService = TestBed.inject(RbdMirroringService);
+
+ formHelper = new FormHelper(component.createBootstrapForm);
+
+ spyOn(rbdMirroringService, 'getSiteName').and.callFake(() => of({ site_name: 'site-A' }));
+ spyOn(rbdMirroringService, 'subscribeSummary').and.callFake((call) =>
+ of({
+ content_data: {
+ pools: [
+ { name: 'pool1', mirror_mode: 'disabled' },
+ { name: 'pool2', mirror_mode: 'disabled' },
+ { name: 'pool3', mirror_mode: 'disabled' }
+ ]
+ }
+ }).subscribe(call)
+ );
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('generate token', () => {
+ beforeEach(() => {
+ spyOn(rbdMirroringService, 'refresh').and.stub();
+ spyOn(component.activeModal, 'close').and.callThrough();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ expect(rbdMirroringService.getSiteName).toHaveBeenCalledTimes(1);
+ expect(rbdMirroringService.subscribeSummary).toHaveBeenCalledTimes(1);
+ expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('should generate a bootstrap token', () => {
+ spyOn(rbdMirroringService, 'setSiteName').and.callFake(() => of({ site_name: 'new-site-A' }));
+ spyOn(rbdMirroringService, 'updatePool').and.callFake(() => of({}));
+ spyOn(rbdMirroringService, 'createBootstrapToken').and.callFake(() => of({ token: 'token' }));
+
+ component.createBootstrapForm.patchValue({
+ siteName: 'new-site-A',
+ pools: { pool1: true, pool3: true }
+ });
+ component.generate();
+ expect(rbdMirroringService.setSiteName).toHaveBeenCalledWith('new-site-A');
+ expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool1', {
+ mirror_mode: 'image'
+ });
+ expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool3', {
+ mirror_mode: 'image'
+ });
+ expect(rbdMirroringService.createBootstrapToken).toHaveBeenCalledWith('pool3');
+ expect(component.createBootstrapForm.getValue('token')).toBe('token');
+ });
+ });
+
+ describe('form validation', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('should require a site name', () => {
+ formHelper.expectErrorChange('siteName', '', 'required');
+ });
+
+ it('should require at least one pool', () => {
+ formHelper.expectError(component.createBootstrapForm.get('pools'), 'requirePool');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts
new file mode 100644
index 000000000..380b636c3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-create-modal/bootstrap-create-modal.component.ts
@@ -0,0 +1,153 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { concat, forkJoin, Subscription } from 'rxjs';
+import { last, tap } from 'rxjs/operators';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-bootstrap-create-modal',
+ templateUrl: './bootstrap-create-modal.component.html',
+ styleUrls: ['./bootstrap-create-modal.component.scss']
+})
+export class BootstrapCreateModalComponent implements OnDestroy, OnInit {
+ siteName: string;
+ pools: any[] = [];
+ token: string;
+
+ subs: Subscription;
+
+ createBootstrapForm: CdFormGroup;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private rbdMirroringService: RbdMirroringService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.createBootstrapForm = new CdFormGroup({
+ siteName: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ pools: new FormGroup(
+ {},
+ {
+ validators: [this.validatePools()]
+ }
+ ),
+ token: new FormControl('', {})
+ });
+ }
+
+ ngOnInit() {
+ this.createBootstrapForm.get('siteName').setValue(this.siteName);
+ this.rbdMirroringService.getSiteName().subscribe((response: any) => {
+ this.createBootstrapForm.get('siteName').setValue(response.site_name);
+ });
+
+ this.subs = this.rbdMirroringService.subscribeSummary((data) => {
+ const pools = data.content_data.pools;
+ this.pools = pools.reduce((acc: any[], pool: Pool) => {
+ acc.push({
+ name: pool['name'],
+ mirror_mode: pool['mirror_mode']
+ });
+ return acc;
+ }, []);
+
+ const poolsControl = this.createBootstrapForm.get('pools') as FormGroup;
+ _.each(this.pools, (pool) => {
+ const poolName = pool['name'];
+ const mirroring_disabled = pool['mirror_mode'] === 'disabled';
+ const control = poolsControl.controls[poolName];
+ if (control) {
+ if (mirroring_disabled && control.disabled) {
+ control.enable();
+ } else if (!mirroring_disabled && control.enabled) {
+ control.disable();
+ control.setValue(true);
+ }
+ } else {
+ poolsControl.addControl(
+ poolName,
+ new FormControl({ value: !mirroring_disabled, disabled: !mirroring_disabled })
+ );
+ }
+ });
+ });
+ }
+
+ ngOnDestroy() {
+ if (this.subs) {
+ this.subs.unsubscribe();
+ }
+ }
+
+ validatePools(): ValidatorFn {
+ return (poolsControl: FormGroup): { [key: string]: any } => {
+ let checkedCount = 0;
+ _.each(poolsControl.controls, (control) => {
+ if (control.value === true) {
+ ++checkedCount;
+ }
+ });
+
+ if (checkedCount > 0) {
+ return null;
+ }
+
+ return { requirePool: true };
+ };
+ }
+
+ generate() {
+ this.createBootstrapForm.get('token').setValue('');
+
+ let bootstrapPoolName = '';
+ const poolNames: string[] = [];
+ const poolsControl = this.createBootstrapForm.get('pools') as FormGroup;
+ _.each(poolsControl.controls, (control, poolName) => {
+ if (control.value === true) {
+ bootstrapPoolName = poolName;
+ if (!control.disabled) {
+ poolNames.push(poolName);
+ }
+ }
+ });
+
+ const poolModeRequest = {
+ mirror_mode: 'image'
+ };
+
+ const apiActionsObs = concat(
+ this.rbdMirroringService.setSiteName(this.createBootstrapForm.getValue('siteName')),
+ forkJoin(
+ poolNames.map((poolName) => this.rbdMirroringService.updatePool(poolName, poolModeRequest))
+ ),
+ this.rbdMirroringService
+ .createBootstrapToken(bootstrapPoolName)
+ .pipe(tap((data: any) => this.createBootstrapForm.get('token').setValue(data['token'])))
+ ).pipe(last());
+
+ const finishHandler = () => {
+ this.rbdMirroringService.refresh();
+ this.createBootstrapForm.setErrors({ cdSubmitButton: true });
+ };
+
+ const taskObs = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/bootstrap/create', {}),
+ call: apiActionsObs
+ });
+ taskObs.subscribe({ error: finishHandler, complete: finishHandler });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html
new file mode 100644
index 000000000..23372d383
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.html
@@ -0,0 +1,96 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n
+ class="modal-title">Import Bootstrap Token</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="importBootstrapForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="importBootstrapForm"
+ novalidate>
+ <div class="modal-body">
+ <p>
+ <ng-container i18n>To import a bootstrap token which was created
+ by a peer site cluster, provide the local site's name, select
+ which pools will have mirroring enabled, provide the generated
+ token, and click&nbsp;<kbd>Import</kbd>.</ng-container>
+ </p>
+
+ <div class="form-group">
+ <label class="col-form-label required"
+ for="siteName"
+ i18n>Site Name</label>
+ <input class="form-control"
+ type="text"
+ placeholder="Name..."
+ i18n-placeholder
+ id="siteName"
+ name="siteName"
+ formControlName="siteName"
+ autofocus>
+ <span *ngIf="importBootstrapForm.showError('siteName', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required.</span>
+ </div>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="direction">
+ <span i18n>Direction</span>
+ </label>
+ <select id="direction"
+ name="direction"
+ class="form-control"
+ formControlName="direction">
+ <option *ngFor="let direction of directions"
+ [value]="direction.key">{{ direction.desc }}</option>
+ </select>
+ </div>
+
+ <div class="form-group"
+ formGroupName="pools">
+ <label class="col-form-label required"
+ for="pools"
+ i18n>Pools</label>
+ <div class="custom-control custom-checkbox"
+ *ngFor="let pool of pools">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="{{ pool.name }}"
+ name="{{ pool.name }}"
+ formControlName="{{ pool.name }}">
+ <label class="custom-control-label"
+ for="{{ pool.name }}">{{ pool.name }}</label>
+ </div>
+ <span *ngIf="importBootstrapForm.showError('pools', formDir, 'requirePool')"
+ class="invalid-feedback"
+ i18n>At least one pool is required.</span>
+ </div>
+
+ <div class="form-group">
+ <label class="col-form-label required"
+ for="token"
+ i18n>Token</label>
+ <textarea class="form-control resize-vertical"
+ placeholder="Generated token..."
+ i18n-placeholder
+ id="token"
+ formControlName="token">
+ </textarea>
+ <span *ngIf="importBootstrapForm.showError('token', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required.</span>
+ <span *ngIf="importBootstrapForm.showError('token', formDir, 'invalidToken')"
+ class="invalid-feedback"
+ i18n>The token is invalid.</span>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="import()"
+ [form]="importBootstrapForm"
+ [submitText]="actionLabels.SUBMIT"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts
new file mode 100644
index 000000000..93c1405df
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.spec.ts
@@ -0,0 +1,131 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { BootstrapImportModalComponent } from './bootstrap-import-modal.component';
+
+describe('BootstrapImportModalComponent', () => {
+ let component: BootstrapImportModalComponent;
+ let fixture: ComponentFixture<BootstrapImportModalComponent>;
+ let notificationService: NotificationService;
+ let rbdMirroringService: RbdMirroringService;
+ let formHelper: FormHelper;
+
+ configureTestBed({
+ declarations: [BootstrapImportModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BootstrapImportModalComponent);
+ component = fixture.componentInstance;
+ component.siteName = 'site-A';
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+
+ rbdMirroringService = TestBed.inject(RbdMirroringService);
+
+ formHelper = new FormHelper(component.importBootstrapForm);
+
+ spyOn(rbdMirroringService, 'getSiteName').and.callFake(() => of({ site_name: 'site-A' }));
+ spyOn(rbdMirroringService, 'subscribeSummary').and.callFake((call) =>
+ of({
+ content_data: {
+ pools: [
+ { name: 'pool1', mirror_mode: 'disabled' },
+ { name: 'pool2', mirror_mode: 'disabled' },
+ { name: 'pool3', mirror_mode: 'disabled' }
+ ]
+ }
+ }).subscribe(call)
+ );
+ });
+
+ it('should import', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('import token', () => {
+ beforeEach(() => {
+ spyOn(rbdMirroringService, 'refresh').and.stub();
+ spyOn(component.activeModal, 'close').and.callThrough();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ expect(rbdMirroringService.getSiteName).toHaveBeenCalledTimes(1);
+ expect(rbdMirroringService.subscribeSummary).toHaveBeenCalledTimes(1);
+ expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('should generate a bootstrap token', () => {
+ spyOn(rbdMirroringService, 'setSiteName').and.callFake(() => of({ site_name: 'new-site-A' }));
+ spyOn(rbdMirroringService, 'updatePool').and.callFake(() => of({}));
+ spyOn(rbdMirroringService, 'importBootstrapToken').and.callFake(() => of({ token: 'token' }));
+
+ component.importBootstrapForm.patchValue({
+ siteName: 'new-site-A',
+ pools: { pool1: true, pool3: true },
+ token: 'e30='
+ });
+ component.import();
+ expect(rbdMirroringService.setSiteName).toHaveBeenCalledWith('new-site-A');
+ expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool1', {
+ mirror_mode: 'image'
+ });
+ expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('pool3', {
+ mirror_mode: 'image'
+ });
+ expect(rbdMirroringService.importBootstrapToken).toHaveBeenCalledWith(
+ 'pool1',
+ 'rx-tx',
+ 'e30='
+ );
+ expect(rbdMirroringService.importBootstrapToken).toHaveBeenCalledWith(
+ 'pool3',
+ 'rx-tx',
+ 'e30='
+ );
+ });
+ });
+
+ describe('form validation', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('should require a site name', () => {
+ formHelper.expectErrorChange('siteName', '', 'required');
+ });
+
+ it('should require at least one pool', () => {
+ formHelper.expectError(component.importBootstrapForm.get('pools'), 'requirePool');
+ });
+
+ it('should require a token', () => {
+ formHelper.expectErrorChange('token', '', 'required');
+ });
+
+ it('should verify token is base64-encoded JSON', () => {
+ formHelper.expectErrorChange('token', 'VEVTVA==', 'invalidToken');
+ formHelper.expectErrorChange('token', 'e2RmYXNqZGZrbH0=', 'invalidToken');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts
new file mode 100644
index 000000000..d79096f6b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/bootstrap-import-modal/bootstrap-import-modal.component.ts
@@ -0,0 +1,187 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { concat, forkJoin, Observable, Subscription } from 'rxjs';
+import { last } from 'rxjs/operators';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-bootstrap-import-modal',
+ templateUrl: './bootstrap-import-modal.component.html',
+ styleUrls: ['./bootstrap-import-modal.component.scss']
+})
+export class BootstrapImportModalComponent implements OnInit, OnDestroy {
+ siteName: string;
+ pools: any[] = [];
+ token: string;
+
+ subs: Subscription;
+
+ importBootstrapForm: CdFormGroup;
+
+ directions: Array<any> = [
+ { key: 'rx-tx', desc: 'Bidirectional' },
+ { key: 'rx', desc: 'Unidirectional (receive-only)' }
+ ];
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private rbdMirroringService: RbdMirroringService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.importBootstrapForm = new CdFormGroup({
+ siteName: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ direction: new FormControl('rx-tx', {}),
+ pools: new FormGroup(
+ {},
+ {
+ validators: [this.validatePools()]
+ }
+ ),
+ token: new FormControl('', {
+ validators: [Validators.required, this.validateToken()]
+ })
+ });
+ }
+
+ ngOnInit() {
+ this.rbdMirroringService.getSiteName().subscribe((response: any) => {
+ this.importBootstrapForm.get('siteName').setValue(response.site_name);
+ });
+
+ this.subs = this.rbdMirroringService.subscribeSummary((data) => {
+ const pools = data.content_data.pools;
+ this.pools = pools.reduce((acc: any[], pool: Pool) => {
+ acc.push({
+ name: pool['name'],
+ mirror_mode: pool['mirror_mode']
+ });
+ return acc;
+ }, []);
+
+ const poolsControl = this.importBootstrapForm.get('pools') as FormGroup;
+ _.each(this.pools, (pool) => {
+ const poolName = pool['name'];
+ const mirroring_disabled = pool['mirror_mode'] === 'disabled';
+ const control = poolsControl.controls[poolName];
+ if (control) {
+ if (mirroring_disabled && control.disabled) {
+ control.enable();
+ } else if (!mirroring_disabled && control.enabled) {
+ control.disable();
+ control.setValue(true);
+ }
+ } else {
+ poolsControl.addControl(
+ poolName,
+ new FormControl({ value: !mirroring_disabled, disabled: !mirroring_disabled })
+ );
+ }
+ });
+ });
+ }
+
+ ngOnDestroy() {
+ if (this.subs) {
+ this.subs.unsubscribe();
+ }
+ }
+
+ validatePools(): ValidatorFn {
+ return (poolsControl: FormGroup): { [key: string]: any } => {
+ let checkedCount = 0;
+ _.each(poolsControl.controls, (control) => {
+ if (control.value === true) {
+ ++checkedCount;
+ }
+ });
+
+ if (checkedCount > 0) {
+ return null;
+ }
+
+ return { requirePool: true };
+ };
+ }
+
+ validateToken(): ValidatorFn {
+ return (token: FormControl): { [key: string]: any } => {
+ try {
+ if (JSON.parse(atob(token.value))) {
+ return null;
+ }
+ } catch (error) {}
+ return { invalidToken: true };
+ };
+ }
+
+ import() {
+ const bootstrapPoolNames: string[] = [];
+ const poolNames: string[] = [];
+ const poolsControl = this.importBootstrapForm.get('pools') as FormGroup;
+ _.each(poolsControl.controls, (control, poolName) => {
+ if (control.value === true) {
+ bootstrapPoolNames.push(poolName);
+ if (!control.disabled) {
+ poolNames.push(poolName);
+ }
+ }
+ });
+
+ const poolModeRequest = {
+ mirror_mode: 'image'
+ };
+
+ let apiActionsObs: Observable<any> = concat(
+ this.rbdMirroringService.setSiteName(this.importBootstrapForm.getValue('siteName')),
+ forkJoin(
+ poolNames.map((poolName) => this.rbdMirroringService.updatePool(poolName, poolModeRequest))
+ )
+ );
+
+ apiActionsObs = bootstrapPoolNames
+ .reduce((obs, poolName) => {
+ return concat(
+ obs,
+ this.rbdMirroringService.importBootstrapToken(
+ poolName,
+ this.importBootstrapForm.getValue('direction'),
+ this.importBootstrapForm.getValue('token')
+ )
+ );
+ }, apiActionsObs)
+ .pipe(last());
+
+ const finishHandler = () => {
+ this.rbdMirroringService.refresh();
+ this.importBootstrapForm.setErrors({ cdSubmitButton: true });
+ };
+
+ const taskObs = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/bootstrap/import', {}),
+ call: apiActionsObs
+ });
+ taskObs.subscribe({
+ error: finishHandler,
+ complete: () => {
+ finishHandler();
+ this.activeModal.close();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.html
new file mode 100644
index 000000000..c7c3bab87
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.html
@@ -0,0 +1,13 @@
+<cd-table [data]="data"
+ columnMode="flex"
+ [columns]="columns"
+ [autoReload]="-1"
+ (fetchData)="refresh()"
+ [status]="tableStatus">
+</cd-table>
+
+<ng-template #healthTmpl
+ let-row="row"
+ let-value="value">
+ <span [ngClass]="row.health_color | mirrorHealthColor">{{ value }}</span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.spec.ts
new file mode 100644
index 000000000..12e3d82b5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.spec.ts
@@ -0,0 +1,28 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MirrorHealthColorPipe } from '../mirror-health-color.pipe';
+import { DaemonListComponent } from './daemon-list.component';
+
+describe('DaemonListComponent', () => {
+ let component: DaemonListComponent;
+ let fixture: ComponentFixture<DaemonListComponent>;
+
+ configureTestBed({
+ declarations: [DaemonListComponent, MirrorHealthColorPipe],
+ imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DaemonListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.ts
new file mode 100644
index 000000000..d55197003
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/daemon-list/daemon-list.component.ts
@@ -0,0 +1,62 @@
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { CephShortVersionPipe } from '~/app/shared/pipes/ceph-short-version.pipe';
+
+@Component({
+ selector: 'cd-mirroring-daemons',
+ templateUrl: './daemon-list.component.html',
+ styleUrls: ['./daemon-list.component.scss']
+})
+export class DaemonListComponent implements OnInit, OnDestroy {
+ @ViewChild('healthTmpl', { static: true })
+ healthTmpl: TemplateRef<any>;
+
+ subs: Subscription;
+
+ data: [];
+ columns: {};
+
+ tableStatus = new TableStatusViewCache();
+
+ constructor(
+ private rbdMirroringService: RbdMirroringService,
+ private cephShortVersionPipe: CephShortVersionPipe
+ ) {}
+
+ ngOnInit() {
+ this.columns = [
+ { prop: 'instance_id', name: $localize`Instance`, flexGrow: 2 },
+ { prop: 'id', name: $localize`ID`, flexGrow: 2 },
+ { prop: 'server_hostname', name: $localize`Hostname`, flexGrow: 2 },
+ {
+ prop: 'version',
+ name: $localize`Version`,
+ pipe: this.cephShortVersionPipe,
+ flexGrow: 2
+ },
+ {
+ prop: 'health',
+ name: $localize`Health`,
+ cellTemplate: this.healthTmpl,
+ flexGrow: 1
+ }
+ ];
+
+ this.subs = this.rbdMirroringService.subscribeSummary((data) => {
+ this.data = data.content_data.daemons;
+ this.tableStatus = new TableStatusViewCache(data.status);
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ refresh() {
+ this.rbdMirroringService.refresh();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.html
new file mode 100644
index 000000000..d4972a41c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.html
@@ -0,0 +1,63 @@
+<ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="image-list">
+ <li ngbNavItem="issues">
+ <a ngbNavLink
+ i18n>Issues ({{ image_error.data.length }})</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="image_error.data"
+ columnMode="flex"
+ [columns]="image_error.columns"
+ [autoReload]="-1"
+ (fetchData)="refresh()"
+ [status]="tableStatus">
+ </cd-table>
+ </ng-template>
+ </li>
+ <li ngbNavItem="syncing">
+ <a ngbNavLink
+ i18n>Syncing ({{ image_syncing.data.length }})</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="image_syncing.data"
+ columnMode="flex"
+ [columns]="image_syncing.columns"
+ [autoReload]="-1"
+ (fetchData)="refresh()"
+ [status]="tableStatus">
+ </cd-table>
+ </ng-template>
+ </li>
+ <li ngbNavItem="ready">
+ <a ngbNavLink
+ i18n>Ready ({{ image_ready.data.length }})</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="image_ready.data"
+ columnMode="flex"
+ [columns]="image_ready.columns"
+ [autoReload]="-1"
+ (fetchData)="refresh()"
+ [status]="tableStatus">
+ </cd-table>
+ </ng-template>
+ </li>
+</ul>
+
+<div [ngbNavOutlet]="nav"></div>
+
+<ng-template #stateTmpl
+ let-row="row"
+ let-value="value">
+ <span [ngClass]="row.state_color | mirrorHealthColor">{{ value }}</span>
+</ng-template>
+
+<ng-template #progressTmpl
+ let-row="row"
+ let-value="value">
+ <div *ngIf="row.state === 'Replaying'">
+ </div>
+ <ngb-progressbar *ngIf="row.state === 'Syncing'"
+ type="info"
+ [value]="value"
+ [showValue]="true"></ngb-progressbar>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.spec.ts
new file mode 100644
index 000000000..b2cc12687
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.spec.ts
@@ -0,0 +1,36 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { NgbNavModule, NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MirrorHealthColorPipe } from '../mirror-health-color.pipe';
+import { ImageListComponent } from './image-list.component';
+
+describe('ImageListComponent', () => {
+ let component: ImageListComponent;
+ let fixture: ComponentFixture<ImageListComponent>;
+
+ configureTestBed({
+ declarations: [ImageListComponent, MirrorHealthColorPipe],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ NgbNavModule,
+ NgbProgressbarModule,
+ HttpClientTestingModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ImageListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.ts
new file mode 100644
index 000000000..4966cc0af
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/image-list/image-list.component.ts
@@ -0,0 +1,99 @@
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+
+@Component({
+ selector: 'cd-mirroring-images',
+ templateUrl: './image-list.component.html',
+ styleUrls: ['./image-list.component.scss']
+})
+export class ImageListComponent implements OnInit, OnDestroy {
+ @ViewChild('stateTmpl', { static: true })
+ stateTmpl: TemplateRef<any>;
+ @ViewChild('syncTmpl', { static: true })
+ syncTmpl: TemplateRef<any>;
+ @ViewChild('progressTmpl', { static: true })
+ progressTmpl: TemplateRef<any>;
+
+ subs: Subscription;
+
+ image_error: Record<string, any> = {
+ data: [],
+ columns: {}
+ };
+ image_syncing: Record<string, any> = {
+ data: [],
+ columns: {}
+ };
+ image_ready: Record<string, any> = {
+ data: [],
+ columns: {}
+ };
+
+ tableStatus = new TableStatusViewCache();
+
+ constructor(private rbdMirroringService: RbdMirroringService) {}
+
+ ngOnInit() {
+ this.image_error.columns = [
+ { prop: 'pool_name', name: $localize`Pool`, flexGrow: 2 },
+ { prop: 'name', name: $localize`Image`, flexGrow: 2 },
+ {
+ prop: 'state',
+ name: $localize`State`,
+ cellTemplate: this.stateTmpl,
+ flexGrow: 1
+ },
+ { prop: 'description', name: $localize`Issue`, flexGrow: 4 }
+ ];
+
+ this.image_syncing.columns = [
+ { prop: 'pool_name', name: $localize`Pool`, flexGrow: 2 },
+ { prop: 'name', name: $localize`Image`, flexGrow: 2 },
+ {
+ prop: 'state',
+ name: $localize`State`,
+ cellTemplate: this.stateTmpl,
+ flexGrow: 1
+ },
+ {
+ prop: 'progress',
+ name: $localize`Progress`,
+ cellTemplate: this.progressTmpl,
+ flexGrow: 2
+ },
+ { prop: 'bytes_per_second', name: $localize`Bytes per second`, flexGrow: 2 },
+ { prop: 'entries_behind_primary', name: $localize`Entries behind primary`, flexGrow: 2 }
+ ];
+
+ this.image_ready.columns = [
+ { prop: 'pool_name', name: $localize`Pool`, flexGrow: 2 },
+ { prop: 'name', name: $localize`Image`, flexGrow: 2 },
+ {
+ prop: 'state',
+ name: $localize`State`,
+ cellTemplate: this.stateTmpl,
+ flexGrow: 1
+ },
+ { prop: 'description', name: $localize`Description`, flexGrow: 4 }
+ ];
+
+ this.subs = this.rbdMirroringService.subscribeSummary((data) => {
+ this.image_error.data = data.content_data.image_error;
+ this.image_syncing.data = data.content_data.image_syncing;
+ this.image_ready.data = data.content_data.image_ready;
+ this.tableStatus = new TableStatusViewCache(data.status);
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ refresh() {
+ this.rbdMirroringService.refresh();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.spec.ts
new file mode 100644
index 000000000..52ff84be1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.spec.ts
@@ -0,0 +1,25 @@
+import { MirrorHealthColorPipe } from './mirror-health-color.pipe';
+
+describe('MirrorHealthColorPipe', () => {
+ const pipe = new MirrorHealthColorPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "warning"', () => {
+ expect(pipe.transform('warning')).toBe('badge badge-warning');
+ });
+
+ it('transforms "error"', () => {
+ expect(pipe.transform('error')).toBe('badge badge-danger');
+ });
+
+ it('transforms "success"', () => {
+ expect(pipe.transform('success')).toBe('badge badge-success');
+ });
+
+ it('transforms others', () => {
+ expect(pipe.transform('abc')).toBe('badge badge-info');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.ts
new file mode 100644
index 000000000..3c25d715e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirror-health-color.pipe.ts
@@ -0,0 +1,17 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'mirrorHealthColor'
+})
+export class MirrorHealthColorPipe implements PipeTransform {
+ transform(value: any): any {
+ if (value === 'warning') {
+ return 'badge badge-warning';
+ } else if (value === 'error') {
+ return 'badge badge-danger';
+ } else if (value === 'success') {
+ return 'badge badge-success';
+ }
+ return 'badge badge-info';
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts
new file mode 100644
index 000000000..dfebe934f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/mirroring.module.ts
@@ -0,0 +1,42 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { NgbNavModule, NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { BootstrapCreateModalComponent } from './bootstrap-create-modal/bootstrap-create-modal.component';
+import { BootstrapImportModalComponent } from './bootstrap-import-modal/bootstrap-import-modal.component';
+import { DaemonListComponent } from './daemon-list/daemon-list.component';
+import { ImageListComponent } from './image-list/image-list.component';
+import { MirrorHealthColorPipe } from './mirror-health-color.pipe';
+import { OverviewComponent } from './overview/overview.component';
+import { PoolEditModeModalComponent } from './pool-edit-mode-modal/pool-edit-mode-modal.component';
+import { PoolEditPeerModalComponent } from './pool-edit-peer-modal/pool-edit-peer-modal.component';
+import { PoolListComponent } from './pool-list/pool-list.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ SharedModule,
+ NgbNavModule,
+ RouterModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbProgressbarModule
+ ],
+ declarations: [
+ BootstrapCreateModalComponent,
+ BootstrapImportModalComponent,
+ DaemonListComponent,
+ ImageListComponent,
+ OverviewComponent,
+ PoolEditModeModalComponent,
+ PoolEditPeerModalComponent,
+ PoolListComponent,
+ MirrorHealthColorPipe
+ ],
+ exports: [OverviewComponent]
+})
+export class MirroringModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html
new file mode 100644
index 000000000..9cdfab939
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html
@@ -0,0 +1,66 @@
+<div class="row">
+ <div class="col-md-12">
+ <form name="rbdmirroringForm"
+ #formDir="ngForm"
+ [formGroup]="rbdmirroringForm"
+ novalidate>
+
+ <div class="d-flex flex-row">
+ <label class="col-form-label"
+ for="siteName"
+ i18n>Site Name</label>
+
+ <div class="col-md-4 input-group mb-3 mr-auto">
+ <input type="text"
+ class="form-control"
+ id="siteName"
+ name="siteName"
+ formControlName="siteName"
+ [attr.disabled]="!editing ? true : null">
+ <div class="input-group-append">
+ <button class="btn btn-light"
+ id="editSiteName"
+ (click)="updateSiteName()">
+ <i [ngClass]="icons.edit"
+ *ngIf="!editing"></i>
+ <i [ngClass]="icons.check"
+ *ngIf="editing"></i>
+ </button>
+ <cd-copy-2-clipboard-button [source]="siteName"
+ [byId]="false">
+ </cd-copy-2-clipboard-button>
+ </div>
+ </div>
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+ </form>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-sm-6">
+ <legend i18n>Daemons</legend>
+
+ <cd-mirroring-daemons>
+ </cd-mirroring-daemons>
+ </div>
+
+ <div class="col-sm-6">
+ <legend i18n>Pools</legend>
+
+ <cd-mirroring-pools>
+ </cd-mirroring-pools>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ <legend i18n>Images</legend>
+ <cd-mirroring-images>
+ </cd-mirroring-images>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.spec.ts
new file mode 100644
index 000000000..d771c2f70
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.spec.ts
@@ -0,0 +1,79 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule, NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DaemonListComponent } from '../daemon-list/daemon-list.component';
+import { ImageListComponent } from '../image-list/image-list.component';
+import { MirrorHealthColorPipe } from '../mirror-health-color.pipe';
+import { PoolListComponent } from '../pool-list/pool-list.component';
+import { OverviewComponent } from './overview.component';
+
+describe('OverviewComponent', () => {
+ let component: OverviewComponent;
+ let fixture: ComponentFixture<OverviewComponent>;
+ let rbdMirroringService: RbdMirroringService;
+
+ configureTestBed({
+ declarations: [
+ DaemonListComponent,
+ ImageListComponent,
+ MirrorHealthColorPipe,
+ OverviewComponent,
+ PoolListComponent
+ ],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ NgbNavModule,
+ NgbProgressbarModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ReactiveFormsModule,
+ ToastrModule.forRoot()
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OverviewComponent);
+ component = fixture.componentInstance;
+ rbdMirroringService = TestBed.inject(RbdMirroringService);
+ component.siteName = 'site-A';
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('edit site name', () => {
+ beforeEach(() => {
+ spyOn(rbdMirroringService, 'getSiteName').and.callFake(() => of({ site_name: 'site-A' }));
+ spyOn(rbdMirroringService, 'refresh').and.stub();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call setSiteName', () => {
+ component.editing = true;
+ spyOn(rbdMirroringService, 'setSiteName').and.callFake(() => of({ site_name: 'new-site-A' }));
+
+ component.rbdmirroringForm.patchValue({
+ siteName: 'new-site-A'
+ });
+ component.updateSiteName();
+ expect(rbdMirroringService.setSiteName).toHaveBeenCalledWith('new-site-A');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts
new file mode 100644
index 000000000..3ee1fa813
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts
@@ -0,0 +1,122 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { Subscription } from 'rxjs';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+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 { BootstrapCreateModalComponent } from '../bootstrap-create-modal/bootstrap-create-modal.component';
+import { BootstrapImportModalComponent } from '../bootstrap-import-modal/bootstrap-import-modal.component';
+
+@Component({
+ selector: 'cd-mirroring',
+ templateUrl: './overview.component.html',
+ styleUrls: ['./overview.component.scss']
+})
+export class OverviewComponent implements OnInit, OnDestroy {
+ rbdmirroringForm: CdFormGroup;
+ permission: Permission;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ modalRef: NgbModalRef;
+ peersExist = true;
+ siteName: any;
+ status: ViewCacheStatus;
+ private subs = new Subscription();
+ editing = false;
+
+ icons = Icons;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdMirroringService: RbdMirroringService,
+ private modalService: ModalService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.permission = this.authStorageService.getPermissions().rbdMirroring;
+
+ const createBootstrapAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.upload,
+ click: () => this.createBootstrapModal(),
+ name: $localize`Create Bootstrap Token`,
+ canBePrimary: () => true,
+ disable: () => false
+ };
+ const importBootstrapAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.download,
+ click: () => this.importBootstrapModal(),
+ name: $localize`Import Bootstrap Token`,
+ disable: () => this.peersExist
+ };
+ this.tableActions = [createBootstrapAction, importBootstrapAction];
+ }
+
+ ngOnInit() {
+ this.createForm();
+ this.subs.add(this.rbdMirroringService.startPolling());
+ this.subs.add(
+ this.rbdMirroringService.subscribeSummary((data) => {
+ this.status = data.content_data.status;
+
+ this.peersExist = !!data.content_data.pools.find((o: Pool) => o['peer_uuids'].length > 0);
+ })
+ );
+ this.rbdMirroringService.getSiteName().subscribe((response: any) => {
+ this.siteName = response.site_name;
+ this.rbdmirroringForm.get('siteName').setValue(this.siteName);
+ });
+ }
+
+ private createForm() {
+ this.rbdmirroringForm = new CdFormGroup({
+ siteName: new FormControl({ value: '', disabled: true })
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ updateSiteName() {
+ if (this.editing) {
+ const action = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/site_name/edit', {}),
+ call: this.rbdMirroringService.setSiteName(this.rbdmirroringForm.getValue('siteName'))
+ });
+
+ action.subscribe({
+ complete: () => {
+ this.rbdMirroringService.refresh();
+ }
+ });
+ }
+ this.editing = !this.editing;
+ }
+
+ createBootstrapModal() {
+ const initialState = {
+ siteName: this.siteName
+ };
+ this.modalRef = this.modalService.show(BootstrapCreateModalComponent, initialState);
+ }
+
+ importBootstrapModal() {
+ const initialState = {
+ siteName: this.siteName
+ };
+ this.modalRef = this.modalService.show(BootstrapImportModalComponent, initialState);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html
new file mode 100644
index 000000000..00fe92b32
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html
@@ -0,0 +1,44 @@
+<cd-modal [modalRef]="activeModal"
+ pageURL="mirroring">
+ <ng-container i18n
+ class="modal-title">Edit pool mirror mode</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="editModeForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="editModeForm"
+ novalidate>
+ <div class="modal-body">
+ <p>
+ <ng-container i18n>To edit the mirror mode for pool&nbsp;
+ <kbd>{{ poolName }}</kbd>, select a new mode from the list and click&nbsp;
+ <kbd>Update</kbd>.</ng-container>
+ </p>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="mirrorMode">
+ <span i18n>Mode</span>
+ </label>
+ <select id="mirrorMode"
+ name="mirrorMode"
+ class="form-control"
+ formControlName="mirrorMode">
+ <option *ngFor="let mirrorMode of mirrorModes"
+ [value]="mirrorMode.id">{{ mirrorMode.name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="editModeForm.showError('mirrorMode', formDir, 'cannotDisable')"
+ i18n>Peer clusters must be removed prior to disabling mirror.</span>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="update()"
+ [form]="editModeForm"
+ [submitText]="actionLabels.UPDATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.spec.ts
new file mode 100644
index 000000000..11ba12334
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.spec.ts
@@ -0,0 +1,86 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { PoolEditModeModalComponent } from './pool-edit-mode-modal.component';
+
+describe('PoolEditModeModalComponent', () => {
+ let component: PoolEditModeModalComponent;
+ let fixture: ComponentFixture<PoolEditModeModalComponent>;
+ let notificationService: NotificationService;
+ let rbdMirroringService: RbdMirroringService;
+ let formHelper: FormHelper;
+ let activatedRoute: ActivatedRouteStub;
+
+ configureTestBed({
+ declarations: [PoolEditModeModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [
+ NgbActiveModal,
+ {
+ provide: ActivatedRoute,
+ useValue: new ActivatedRouteStub({ pool_name: 'somePool' })
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PoolEditModeModalComponent);
+ component = fixture.componentInstance;
+ component.poolName = 'somePool';
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+
+ rbdMirroringService = TestBed.inject(RbdMirroringService);
+ activatedRoute = <ActivatedRouteStub>TestBed.inject(ActivatedRoute);
+
+ formHelper = new FormHelper(component.editModeForm);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('update pool mode', () => {
+ beforeEach(() => {
+ spyOn(component.activeModal, 'close').and.callThrough();
+ });
+
+ it('should call updatePool', () => {
+ activatedRoute.setParams({ pool_name: 'somePool' });
+ spyOn(rbdMirroringService, 'updatePool').and.callFake(() => of(''));
+
+ component.editModeForm.patchValue({ mirrorMode: 'disabled' });
+ component.update();
+ expect(rbdMirroringService.updatePool).toHaveBeenCalledWith('somePool', {
+ mirror_mode: 'disabled'
+ });
+ });
+ });
+
+ describe('form validation', () => {
+ it('should prevent disabling mirroring if peers exist', () => {
+ component.peerExists = true;
+ formHelper.expectErrorChange('mirrorMode', 'disabled', 'cannotDisable');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.ts
new file mode 100644
index 000000000..ef30c888c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.ts
@@ -0,0 +1,111 @@
+import { Location } from '@angular/common';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { AbstractControl, FormControl, Validators } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { Subscription } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { PoolEditModeResponseModel } from './pool-edit-mode-response.model';
+
+@Component({
+ selector: 'cd-pool-edit-mode-modal',
+ templateUrl: './pool-edit-mode-modal.component.html',
+ styleUrls: ['./pool-edit-mode-modal.component.scss']
+})
+export class PoolEditModeModalComponent implements OnInit, OnDestroy {
+ poolName: string;
+
+ subs: Subscription;
+
+ editModeForm: CdFormGroup;
+ bsConfig = {
+ containerClass: 'theme-default'
+ };
+ pattern: string;
+
+ response: PoolEditModeResponseModel;
+ peerExists = false;
+
+ mirrorModes: Array<{ id: string; name: string }> = [
+ { id: 'disabled', name: $localize`Disabled` },
+ { id: 'pool', name: $localize`Pool` },
+ { id: 'image', name: $localize`Image` }
+ ];
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private rbdMirroringService: RbdMirroringService,
+ private taskWrapper: TaskWrapperService,
+ private route: ActivatedRoute,
+ private location: Location
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.editModeForm = new CdFormGroup({
+ mirrorMode: new FormControl('', {
+ validators: [Validators.required, this.validateMode.bind(this)]
+ })
+ });
+ }
+
+ ngOnInit() {
+ this.route.params.subscribe((params: { pool_name: string }) => {
+ this.poolName = params.pool_name;
+ });
+ this.pattern = `${this.poolName}`;
+ this.rbdMirroringService.getPool(this.poolName).subscribe((resp: PoolEditModeResponseModel) => {
+ this.setResponse(resp);
+ });
+
+ this.subs = this.rbdMirroringService.subscribeSummary((data) => {
+ this.peerExists = false;
+ const poolData = data.content_data.pools;
+ const pool = poolData.find((o: any) => this.poolName === o['name']);
+ this.peerExists = pool && pool['peer_uuids'].length;
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ validateMode(control: AbstractControl) {
+ if (control.value === 'disabled' && this.peerExists) {
+ return { cannotDisable: { value: control.value } };
+ }
+ return null;
+ }
+
+ setResponse(response: PoolEditModeResponseModel) {
+ this.editModeForm.get('mirrorMode').setValue(response.mirror_mode);
+ }
+
+ update() {
+ const request = new PoolEditModeResponseModel();
+ request.mirror_mode = this.editModeForm.getValue('mirrorMode');
+
+ const action = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/pool/edit', {
+ pool_name: this.poolName
+ }),
+ call: this.rbdMirroringService.updatePool(this.poolName, request)
+ });
+
+ action.subscribe({
+ error: () => this.editModeForm.setErrors({ cdSubmitButton: true }),
+ complete: () => {
+ this.rbdMirroringService.refresh();
+ this.location.back();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-response.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-response.model.ts
new file mode 100644
index 000000000..ba8bc677c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-response.model.ts
@@ -0,0 +1,3 @@
+export class PoolEditModeResponseModel {
+ mirror_mode: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html
new file mode 100644
index 000000000..97774ebe3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html
@@ -0,0 +1,100 @@
+<cd-modal [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>{mode, select, edit {Edit} other {Add}} pool mirror peer</span>
+
+ <ng-container class="modal-content">
+ <form name="editPeerForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="editPeerForm"
+ novalidate>
+ <div class="modal-body">
+ <p>
+ <span i18n>{mode, select, edit {Edit} other {Add}} the pool
+ mirror peer attributes for pool <kbd>{{ poolName }}</kbd> and click
+ <kbd>Submit</kbd>.</span>
+ </p>
+
+ <div class="form-group">
+ <label class="col-form-label required"
+ for="clusterName"
+ i18n>Cluster Name</label>
+ <input class="form-control"
+ type="text"
+ placeholder="Name..."
+ i18n-placeholder
+ id="clusterName"
+ name="clusterName"
+ formControlName="clusterName"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="editPeerForm.showError('clusterName', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="editPeerForm.showError('clusterName', formDir, 'invalidClusterName')"
+ i18n>The cluster name is not valid.</span>
+ </div>
+
+ <div class="form-group">
+ <label class="col-form-label required"
+ for="clientID"
+ i18n>CephX ID</label>
+ <input class="form-control"
+ type="text"
+ placeholder="CephX ID..."
+ i18n-placeholder
+ id="clientID"
+ name="clientID"
+ formControlName="clientID">
+ <span class="invalid-feedback"
+ *ngIf="editPeerForm.showError('clientID', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="editPeerForm.showError('clientID', formDir, 'invalidClientID')"
+ i18n>The CephX ID is not valid.</span>
+ </div>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="monAddr">
+ <span i18n>Monitor Addresses</span>
+ </label>
+ <input class="form-control"
+ type="text"
+ placeholder="Comma-delimited addresses..."
+ i18n-placeholder
+ id="monAddr"
+ name="monAddr"
+ formControlName="monAddr">
+ <span class="invalid-feedback"
+ *ngIf="editPeerForm.showError('monAddr', formDir, 'invalidMonAddr')"
+ i18n>The monitory address is not valid.</span>
+ </div>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="key">
+ <span i18n>CephX Key</span>
+ </label>
+ <input class="form-control"
+ type="text"
+ placeholder="Base64-encoded key..."
+ i18n-placeholder
+ id="key"
+ name="key"
+ formControlName="key">
+ <span class="invalid-feedback"
+ *ngIf="editPeerForm.showError('key', formDir, 'invalidKey')"
+ i18n>CephX key must be base64 encoded.</span>
+ </div>
+
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="update()"
+ [form]="editPeerForm"
+ [submitText]="actionLabels.SUBMIT"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.spec.ts
new file mode 100644
index 000000000..96efaa539
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.spec.ts
@@ -0,0 +1,148 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { PoolEditPeerModalComponent } from './pool-edit-peer-modal.component';
+import { PoolEditPeerResponseModel } from './pool-edit-peer-response.model';
+
+describe('PoolEditPeerModalComponent', () => {
+ let component: PoolEditPeerModalComponent;
+ let fixture: ComponentFixture<PoolEditPeerModalComponent>;
+ let notificationService: NotificationService;
+ let rbdMirroringService: RbdMirroringService;
+ let formHelper: FormHelper;
+
+ configureTestBed({
+ declarations: [PoolEditPeerModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PoolEditPeerModalComponent);
+ component = fixture.componentInstance;
+ component.mode = 'add';
+ component.poolName = 'somePool';
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+
+ rbdMirroringService = TestBed.inject(RbdMirroringService);
+
+ formHelper = new FormHelper(component.editPeerForm);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('add pool peer', () => {
+ beforeEach(() => {
+ component.mode = 'add';
+ component.peerUUID = undefined;
+ spyOn(rbdMirroringService, 'refresh').and.stub();
+ spyOn(component.activeModal, 'close').and.callThrough();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call addPeer', () => {
+ spyOn(rbdMirroringService, 'addPeer').and.callFake(() => of(''));
+
+ component.editPeerForm.patchValue({
+ clusterName: 'cluster',
+ clientID: 'id',
+ monAddr: 'mon_host',
+ key: 'dGVzdA=='
+ });
+
+ component.update();
+ expect(rbdMirroringService.addPeer).toHaveBeenCalledWith('somePool', {
+ cluster_name: 'cluster',
+ client_id: 'id',
+ mon_host: 'mon_host',
+ key: 'dGVzdA=='
+ });
+ });
+ });
+
+ describe('edit pool peer', () => {
+ beforeEach(() => {
+ component.mode = 'edit';
+ component.peerUUID = 'somePeer';
+
+ const response = new PoolEditPeerResponseModel();
+ response.uuid = 'somePeer';
+ response.cluster_name = 'cluster';
+ response.client_id = 'id';
+ response.mon_host = '1.2.3.4:1234';
+ response.key = 'dGVzdA==';
+
+ spyOn(rbdMirroringService, 'getPeer').and.callFake(() => of(response));
+ spyOn(rbdMirroringService, 'refresh').and.stub();
+ spyOn(component.activeModal, 'close').and.callThrough();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ expect(rbdMirroringService.getPeer).toHaveBeenCalledWith('somePool', 'somePeer');
+ expect(rbdMirroringService.refresh).toHaveBeenCalledTimes(1);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call updatePeer', () => {
+ spyOn(rbdMirroringService, 'updatePeer').and.callFake(() => of(''));
+
+ component.update();
+ expect(rbdMirroringService.updatePeer).toHaveBeenCalledWith('somePool', 'somePeer', {
+ cluster_name: 'cluster',
+ client_id: 'id',
+ mon_host: '1.2.3.4:1234',
+ key: 'dGVzdA=='
+ });
+ });
+ });
+
+ describe('form validation', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('should validate cluster name', () => {
+ formHelper.expectErrorChange('clusterName', '', 'required');
+ formHelper.expectErrorChange('clusterName', ' ', 'invalidClusterName');
+ });
+
+ it('should validate client ID', () => {
+ formHelper.expectErrorChange('clientID', '', 'required');
+ formHelper.expectErrorChange('clientID', 'client.id', 'invalidClientID');
+ });
+
+ it('should validate monitor address', () => {
+ formHelper.expectErrorChange('monAddr', '@', 'invalidMonAddr');
+ });
+
+ it('should validate key', () => {
+ formHelper.expectErrorChange('key', '(', 'invalidKey');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.ts
new file mode 100644
index 000000000..6569c3b24
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.ts
@@ -0,0 +1,141 @@
+import { Component, OnInit } from '@angular/core';
+import { AbstractControl, FormControl, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { PoolEditPeerResponseModel } from './pool-edit-peer-response.model';
+
+@Component({
+ selector: 'cd-pool-edit-peer-modal',
+ templateUrl: './pool-edit-peer-modal.component.html',
+ styleUrls: ['./pool-edit-peer-modal.component.scss']
+})
+export class PoolEditPeerModalComponent implements OnInit {
+ mode: string;
+ poolName: string;
+ peerUUID: string;
+
+ editPeerForm: CdFormGroup;
+ bsConfig = {
+ containerClass: 'theme-default'
+ };
+ pattern: string;
+
+ response: PoolEditPeerResponseModel;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private rbdMirroringService: RbdMirroringService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.editPeerForm = new CdFormGroup({
+ clusterName: new FormControl('', {
+ validators: [Validators.required, this.validateClusterName]
+ }),
+ clientID: new FormControl('', {
+ validators: [Validators.required, this.validateClientID]
+ }),
+ monAddr: new FormControl('', {
+ validators: [this.validateMonAddr]
+ }),
+ key: new FormControl('', {
+ validators: [this.validateKey]
+ })
+ });
+ }
+
+ ngOnInit() {
+ this.pattern = `${this.poolName}/${this.peerUUID}`;
+ if (this.mode === 'edit') {
+ this.rbdMirroringService
+ .getPeer(this.poolName, this.peerUUID)
+ .subscribe((resp: PoolEditPeerResponseModel) => {
+ this.setResponse(resp);
+ });
+ }
+ }
+
+ validateClusterName(control: AbstractControl) {
+ if (!control.value.match(/^[\w\-_]*$/)) {
+ return { invalidClusterName: { value: control.value } };
+ }
+
+ return undefined;
+ }
+
+ validateClientID(control: AbstractControl) {
+ if (!control.value.match(/^(?!client\.)[\w\-_.]*$/)) {
+ return { invalidClientID: { value: control.value } };
+ }
+
+ return undefined;
+ }
+
+ validateMonAddr(control: AbstractControl) {
+ if (!control.value.match(/^[,; ]*([\w.\-_\[\]]+(:[\d]+)?[,; ]*)*$/)) {
+ return { invalidMonAddr: { value: control.value } };
+ }
+
+ return undefined;
+ }
+
+ validateKey(control: AbstractControl) {
+ try {
+ if (control.value === '' || !!atob(control.value)) {
+ return null;
+ }
+ } catch (error) {}
+ return { invalidKey: { value: control.value } };
+ }
+
+ setResponse(response: PoolEditPeerResponseModel) {
+ this.response = response;
+ this.editPeerForm.get('clusterName').setValue(response.cluster_name);
+ this.editPeerForm.get('clientID').setValue(response.client_id);
+ this.editPeerForm.get('monAddr').setValue(response.mon_host);
+ this.editPeerForm.get('key').setValue(response.key);
+ }
+
+ update() {
+ const request = new PoolEditPeerResponseModel();
+ request.cluster_name = this.editPeerForm.getValue('clusterName');
+ request.client_id = this.editPeerForm.getValue('clientID');
+ request.mon_host = this.editPeerForm.getValue('monAddr');
+ request.key = this.editPeerForm.getValue('key');
+
+ let action;
+ if (this.mode === 'edit') {
+ action = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/peer/edit', {
+ pool_name: this.poolName
+ }),
+ call: this.rbdMirroringService.updatePeer(this.poolName, this.peerUUID, request)
+ });
+ } else {
+ action = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/peer/add', {
+ pool_name: this.poolName
+ }),
+ call: this.rbdMirroringService.addPeer(this.poolName, request)
+ });
+ }
+
+ action.subscribe({
+ error: () => this.editPeerForm.setErrors({ cdSubmitButton: true }),
+ complete: () => {
+ this.rbdMirroringService.refresh();
+ this.activeModal.close();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-response.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-response.model.ts
new file mode 100644
index 000000000..fb9c67fcb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-response.model.ts
@@ -0,0 +1,7 @@
+export class PoolEditPeerResponseModel {
+ cluster_name: string;
+ client_id: string;
+ mon_host: string;
+ key: string;
+ uuid: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.html
new file mode 100644
index 000000000..1e4e72df1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.html
@@ -0,0 +1,23 @@
+<cd-table [data]="data"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="name"
+ forceIdentifier="true"
+ [autoReload]="-1"
+ (fetchData)="refresh()"
+ selectionType="single"
+ (updateSelection)="updateSelection($event)"
+ [status]="tableStatus">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+</cd-table>
+
+<ng-template #healthTmpl
+ let-row="row"
+ let-value="value">
+ <span [ngClass]="row.health_color | mirrorHealthColor">{{ value }}</span>
+</ng-template>
+<router-outlet name="modal"></router-outlet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.spec.ts
new file mode 100644
index 000000000..bb5865039
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.spec.ts
@@ -0,0 +1,37 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MirrorHealthColorPipe } from '../mirror-health-color.pipe';
+import { PoolListComponent } from './pool-list.component';
+
+describe('PoolListComponent', () => {
+ let component: PoolListComponent;
+ let fixture: ComponentFixture<PoolListComponent>;
+
+ configureTestBed({
+ declarations: [PoolListComponent, MirrorHealthColorPipe],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PoolListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.ts
new file mode 100644
index 000000000..a5e1c9e4b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/pool-list/pool-list.component.ts
@@ -0,0 +1,174 @@
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { Observable, Subscriber, Subscription } from 'rxjs';
+
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+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 { PoolEditPeerModalComponent } from '../pool-edit-peer-modal/pool-edit-peer-modal.component';
+
+const BASE_URL = '/block/mirroring';
+@Component({
+ selector: 'cd-mirroring-pools',
+ templateUrl: './pool-list.component.html',
+ styleUrls: ['./pool-list.component.scss']
+})
+export class PoolListComponent implements OnInit, OnDestroy {
+ @ViewChild('healthTmpl', { static: true })
+ healthTmpl: TemplateRef<any>;
+
+ subs: Subscription;
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+
+ modalRef: NgbModalRef;
+
+ data: [];
+ columns: {};
+
+ tableStatus = new TableStatusViewCache();
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdMirroringService: RbdMirroringService,
+ private modalService: ModalService,
+ private taskWrapper: TaskWrapperService,
+ private router: Router
+ ) {
+ this.data = [];
+ this.permission = this.authStorageService.getPermissions().rbdMirroring;
+
+ const editModeAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.editModeModal(),
+ name: $localize`Edit Mode`,
+ canBePrimary: () => true
+ };
+ const addPeerAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ name: $localize`Add Peer`,
+ click: () => this.editPeersModal('add'),
+ disable: () => !this.selection.first() || this.selection.first().mirror_mode === 'disabled',
+ visible: () => !this.getPeerUUID(),
+ canBePrimary: () => false
+ };
+ const editPeerAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.exchange,
+ name: $localize`Edit Peer`,
+ click: () => this.editPeersModal('edit'),
+ visible: () => !!this.getPeerUUID()
+ };
+ const deletePeerAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ name: $localize`Delete Peer`,
+ click: () => this.deletePeersModal(),
+ visible: () => !!this.getPeerUUID()
+ };
+ this.tableActions = [editModeAction, addPeerAction, editPeerAction, deletePeerAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ { prop: 'name', name: $localize`Name`, flexGrow: 2 },
+ { prop: 'mirror_mode', name: $localize`Mode`, flexGrow: 2 },
+ { prop: 'leader_id', name: $localize`Leader`, flexGrow: 2 },
+ { prop: 'image_local_count', name: $localize`# Local`, flexGrow: 2 },
+ { prop: 'image_remote_count', name: $localize`# Remote`, flexGrow: 2 },
+ {
+ prop: 'health',
+ name: $localize`Health`,
+ cellTemplate: this.healthTmpl,
+ flexGrow: 1
+ }
+ ];
+
+ this.subs = this.rbdMirroringService.subscribeSummary((data) => {
+ this.data = data.content_data.pools;
+ this.tableStatus = new TableStatusViewCache(data.status);
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ refresh() {
+ this.rbdMirroringService.refresh();
+ }
+
+ editModeModal() {
+ this.router.navigate([
+ BASE_URL,
+ { outlets: { modal: [URLVerbs.EDIT, this.selection.first().name] } }
+ ]);
+ }
+
+ editPeersModal(mode: string) {
+ const initialState = {
+ poolName: this.selection.first().name,
+ mode: mode
+ };
+ if (mode === 'edit') {
+ initialState['peerUUID'] = this.getPeerUUID();
+ }
+ this.modalRef = this.modalService.show(PoolEditPeerModalComponent, initialState);
+ }
+
+ deletePeersModal() {
+ const poolName = this.selection.first().name;
+ const peerUUID = this.getPeerUUID();
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: $localize`mirror peer`,
+ itemNames: [`${poolName} (${peerUUID})`],
+ submitActionObservable: () =>
+ new Observable((observer: Subscriber<any>) => {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/mirroring/peer/delete', {
+ pool_name: poolName
+ }),
+ call: this.rbdMirroringService.deletePeer(poolName, peerUUID)
+ })
+ .subscribe({
+ error: (resp) => observer.error(resp),
+ complete: () => {
+ this.rbdMirroringService.refresh();
+ observer.complete();
+ }
+ });
+ })
+ });
+ }
+
+ getPeerUUID(): any {
+ const selection = this.selection.first();
+ const pool = this.data.find((o) => selection && selection.name === o['name']);
+ if (pool && pool['peer_uuids']) {
+ return pool['peer_uuids'][0];
+ }
+
+ return undefined;
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html
new file mode 100644
index 000000000..130aa3286
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html
@@ -0,0 +1,74 @@
+<fieldset #cfgFormGroup
+ [formGroup]="form.get('configuration')">
+ <legend i18n>RBD Configuration</legend>
+
+ <div *ngFor="let section of rbdConfigurationService.sections"
+ class="col-12">
+ <h4 class="cd-header">
+ <span (click)="toggleSectionVisibility(section.class)"
+ class="collapsible">
+ {{ section.heading }} <i [ngClass]="!sectionVisibility[section.class] ? icons.addCircle : icons.minusCircle"
+ aria-hidden="true"></i>
+ </span>
+ </h4>
+ <div class="{{ section.class }}"
+ [hidden]="!sectionVisibility[section.class]">
+ <div class="form-group row"
+ *ngFor="let option of section.options">
+ <label class="cd-col-form-label"
+ [for]="option.name">{{ option.displayName }}<cd-helper>{{ option.description }}</cd-helper></label>
+
+ <div class="cd-col-form-input {{ section.heading }}">
+ <div class="input-group">
+ <ng-container [ngSwitch]="option.type">
+ <ng-container *ngSwitchCase="configurationType.milliseconds">
+ <input [id]="option.name"
+ [name]="option.name"
+ [formControlName]="option.name"
+ type="text"
+ class="form-control"
+ [ngDataReady]="ngDataReady"
+ cdMilliseconds>
+ </ng-container>
+ <ng-container *ngSwitchCase="configurationType.bps">
+ <input [id]="option.name"
+ [name]="option.name"
+ [formControlName]="option.name"
+ type="text"
+ class="form-control"
+ defaultUnit="b"
+ [ngDataReady]="ngDataReady"
+ cdDimlessBinaryPerSecond>
+ </ng-container>
+ <ng-container *ngSwitchCase="configurationType.iops">
+ <input [id]="option.name"
+ [name]="option.name"
+ [formControlName]="option.name"
+ type="text"
+ class="form-control"
+ [ngDataReady]="ngDataReady"
+ cdIops>
+ </ng-container>
+ </ng-container>
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ type="button"
+ data-toggle="button"
+ [ngClass]="{'active': isDisabled(option.name)}"
+ title="Remove the local configuration value. The parent configuration value will be inherited and used instead."
+ i18n-title
+ (click)="reset(option.name)">
+ <i [ngClass]="[icons.erase]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ <span i18n
+ class="invalid-feedback"
+ *ngIf="form.showError('configuration.' + option.name, cfgFormGroup, 'min')">The minimum value is 0</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+</fieldset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss
new file mode 100644
index 000000000..ba6460c32
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss
@@ -0,0 +1,4 @@
+.collapsible {
+ cursor: pointer;
+ user-select: none;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts
new file mode 100644
index 000000000..833a649da
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts
@@ -0,0 +1,294 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+
+import { ReplaySubject } from 'rxjs';
+
+import { DirectivesModule } from '~/app/shared/directives/directives.module';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { RbdConfigurationSourceField } from '~/app/shared/models/configuration';
+import { DimlessBinaryPerSecondPipe } from '~/app/shared/pipes/dimless-binary-per-second.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { RbdConfigurationService } from '~/app/shared/services/rbd-configuration.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { RbdConfigurationFormComponent } from './rbd-configuration-form.component';
+
+describe('RbdConfigurationFormComponent', () => {
+ let component: RbdConfigurationFormComponent;
+ let fixture: ComponentFixture<RbdConfigurationFormComponent>;
+ let sections: any[];
+ let fh: FormHelper;
+
+ configureTestBed({
+ imports: [ReactiveFormsModule, DirectivesModule, SharedModule],
+ declarations: [RbdConfigurationFormComponent],
+ providers: [RbdConfigurationService, FormatterService, DimlessBinaryPerSecondPipe]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdConfigurationFormComponent);
+ component = fixture.componentInstance;
+ component.form = new CdFormGroup({}, null);
+ fh = new FormHelper(component.form);
+ fixture.detectChanges();
+ sections = TestBed.inject(RbdConfigurationService).sections;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should create all form fields mentioned in RbdConfiguration::OPTIONS', () => {
+ /* Test form creation on a TypeScript level */
+ const actual = Object.keys((component.form.get('configuration') as CdFormGroup).controls);
+ const expected = sections
+ .map((section) => section.options)
+ .reduce((a, b) => a.concat(b))
+ .map((option: Record<string, any>) => option.name);
+ expect(actual).toEqual(expected);
+
+ /* Test form creation on a template level */
+ const controlDebugElements = fixture.debugElement.queryAll(By.css('input.form-control'));
+ expect(controlDebugElements.length).toBe(expected.length);
+ controlDebugElements.forEach((element) => expect(element.nativeElement).toBeTruthy());
+ });
+
+ it('should only contain values of changed controls if submitted', () => {
+ let values = {};
+ component.changes.subscribe((getDirtyValues: Function) => {
+ values = getDirtyValues();
+ });
+ fh.setValue('configuration.rbd_qos_bps_limit', 0, true);
+ fixture.detectChanges();
+
+ expect(values).toEqual({ rbd_qos_bps_limit: 0 });
+ });
+
+ describe('test loading of initial data for editing', () => {
+ beforeEach(() => {
+ component.initializeData = new ReplaySubject<any>(1);
+ fixture.detectChanges();
+ component.ngOnInit();
+ });
+
+ it('should return dirty values without any units', () => {
+ let dirtyValues = {};
+ component.changes.subscribe((getDirtyValues: Function) => {
+ dirtyValues = getDirtyValues();
+ });
+
+ fh.setValue('configuration.rbd_qos_bps_limit', 55, true);
+ fh.setValue('configuration.rbd_qos_iops_limit', 22, true);
+
+ expect(dirtyValues['rbd_qos_bps_limit']).toBe(55);
+ expect(dirtyValues['rbd_qos_iops_limit']).toBe(22);
+ });
+
+ it('should load initial data into forms', () => {
+ component.initializeData.next({
+ initialData: [
+ {
+ name: 'rbd_qos_bps_limit',
+ value: 55,
+ source: 1
+ }
+ ],
+ sourceType: RbdConfigurationSourceField.pool
+ });
+
+ expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('55 B/s');
+ });
+
+ it('should not load initial data if the source is not the pool itself', () => {
+ component.initializeData.next({
+ initialData: [
+ {
+ name: 'rbd_qos_bps_limit',
+ value: 55,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_iops_limit',
+ value: 22,
+ source: RbdConfigurationSourceField.global
+ }
+ ],
+ sourceType: RbdConfigurationSourceField.pool
+ });
+
+ expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('0 IOPS');
+ expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('0 B/s');
+ });
+
+ it('should not load initial data if the source is not the image itself', () => {
+ component.initializeData.next({
+ initialData: [
+ {
+ name: 'rbd_qos_bps_limit',
+ value: 55,
+ source: RbdConfigurationSourceField.pool
+ },
+ {
+ name: 'rbd_qos_iops_limit',
+ value: 22,
+ source: RbdConfigurationSourceField.global
+ }
+ ],
+ sourceType: RbdConfigurationSourceField.image
+ });
+
+ expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('0 IOPS');
+ expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('0 B/s');
+ });
+
+ it('should always have formatted results', () => {
+ component.initializeData.next({
+ initialData: [
+ {
+ name: 'rbd_qos_bps_limit',
+ value: 55,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_iops_limit',
+ value: 22,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_read_bps_limit',
+ value: null, // incorrect type
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_read_bps_limit',
+ value: undefined, // incorrect type
+ source: RbdConfigurationSourceField.image
+ }
+ ],
+ sourceType: RbdConfigurationSourceField.image
+ });
+
+ expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('22 IOPS');
+ expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('55 B/s');
+ expect(component.form.getValue('configuration.rbd_qos_read_bps_limit')).toEqual('0 B/s');
+ expect(component.form.getValue('configuration.rbd_qos_read_bps_limit')).toEqual('0 B/s');
+ });
+ });
+
+ it('should reset the corresponding form field correctly', () => {
+ const fieldName = 'rbd_qos_bps_limit';
+ const getValue = () => component.form.get(`configuration.${fieldName}`).value;
+
+ // Initialization
+ fh.setValue(`configuration.${fieldName}`, 418, true);
+ expect(getValue()).toBe(418);
+
+ // Reset
+ component.reset(fieldName);
+ expect(getValue()).toBe(null);
+
+ // Restore
+ component.reset(fieldName);
+ expect(getValue()).toBe(418);
+
+ // Reset
+ component.reset(fieldName);
+ expect(getValue()).toBe(null);
+
+ // Restore
+ component.reset(fieldName);
+ expect(getValue()).toBe(418);
+ });
+
+ describe('should verify that getDirtyValues() returns correctly', () => {
+ let data: any;
+
+ beforeEach(() => {
+ component.initializeData = new ReplaySubject<any>(1);
+ fixture.detectChanges();
+ component.ngOnInit();
+ data = {
+ initialData: [
+ {
+ name: 'rbd_qos_bps_limit',
+ value: 0,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_iops_limit',
+ value: 0,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_read_bps_limit',
+ value: 0,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_read_iops_limit',
+ value: 0,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_read_iops_burst',
+ value: 0,
+ source: RbdConfigurationSourceField.image
+ },
+ {
+ name: 'rbd_qos_write_bps_burst',
+ value: undefined,
+ source: RbdConfigurationSourceField.global
+ },
+ {
+ name: 'rbd_qos_write_iops_burst',
+ value: null,
+ source: RbdConfigurationSourceField.global
+ }
+ ],
+ sourceType: RbdConfigurationSourceField.image
+ };
+ component.initializeData.next(data);
+ });
+
+ it('should return an empty object', () => {
+ expect(component.getDirtyValues()).toEqual({});
+ expect(component.getDirtyValues(true, RbdConfigurationSourceField.image)).toEqual({});
+ });
+
+ it('should return dirty values', () => {
+ component.form.get('configuration.rbd_qos_write_bps_burst').markAsDirty();
+ expect(component.getDirtyValues()).toEqual({ rbd_qos_write_bps_burst: 0 });
+
+ component.form.get('configuration.rbd_qos_write_iops_burst').markAsDirty();
+ expect(component.getDirtyValues()).toEqual({
+ rbd_qos_write_iops_burst: 0,
+ rbd_qos_write_bps_burst: 0
+ });
+ });
+
+ it('should also return all local values if they do not contain their initial values', () => {
+ // Change value for all options
+ data.initialData = data.initialData.map((o: Record<string, any>) => {
+ o.value = 22;
+ return o;
+ });
+
+ // Mark some dirty
+ ['rbd_qos_read_iops_limit', 'rbd_qos_write_bps_burst'].forEach((option) => {
+ component.form.get(`configuration.${option}`).markAsDirty();
+ });
+
+ expect(component.getDirtyValues(true, RbdConfigurationSourceField.image)).toEqual({
+ rbd_qos_read_iops_limit: 0,
+ rbd_qos_write_bps_burst: 0
+ });
+ });
+
+ it('should throw an error if used incorrectly', () => {
+ expect(() => component.getDirtyValues(true)).toThrowError(
+ /^ProgrammingError: If local values shall be included/
+ );
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts
new file mode 100644
index 000000000..3ced71f02
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts
@@ -0,0 +1,166 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import _ from 'lodash';
+import { ReplaySubject } from 'rxjs';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import {
+ RbdConfigurationEntry,
+ RbdConfigurationSourceField,
+ RbdConfigurationType
+} from '~/app/shared/models/configuration';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { RbdConfigurationService } from '~/app/shared/services/rbd-configuration.service';
+
+@Component({
+ selector: 'cd-rbd-configuration-form',
+ templateUrl: './rbd-configuration-form.component.html',
+ styleUrls: ['./rbd-configuration-form.component.scss']
+})
+export class RbdConfigurationFormComponent implements OnInit {
+ @Input()
+ form: CdFormGroup;
+ @Input()
+ initializeData = new ReplaySubject<{
+ initialData: RbdConfigurationEntry[];
+ sourceType: RbdConfigurationSourceField;
+ }>(1);
+ @Output()
+ changes = new EventEmitter<any>();
+
+ icons = Icons;
+
+ ngDataReady = new EventEmitter<any>();
+ initialData: RbdConfigurationEntry[];
+ configurationType = RbdConfigurationType;
+ sectionVisibility: { [key: string]: boolean } = {};
+
+ constructor(
+ public formatterService: FormatterService,
+ public rbdConfigurationService: RbdConfigurationService
+ ) {}
+
+ ngOnInit() {
+ const configFormGroup = this.createConfigurationFormGroup();
+ this.form.addControl('configuration', configFormGroup);
+
+ // Listen to changes and emit the values to the parent component
+ configFormGroup.valueChanges.subscribe(() => {
+ this.changes.emit(this.getDirtyValues.bind(this));
+ });
+
+ if (this.initializeData) {
+ this.initializeData.subscribe((data: Record<string, any>) => {
+ this.initialData = data.initialData;
+ const dataType = data.sourceType;
+ this.rbdConfigurationService.getWritableOptionFields().forEach((option) => {
+ const optionData = data.initialData
+ .filter((entry: Record<string, any>) => entry.name === option.name)
+ .pop();
+ if (optionData && optionData['source'] === dataType) {
+ this.form.get(`configuration.${option.name}`).setValue(optionData['value']);
+ }
+ });
+ this.ngDataReady.emit();
+ });
+ }
+
+ this.rbdConfigurationService
+ .getWritableSections()
+ .forEach((section) => (this.sectionVisibility[section.class] = false));
+ }
+
+ getDirtyValues(includeLocalValues = false, localFieldType?: RbdConfigurationSourceField) {
+ if (includeLocalValues && !localFieldType) {
+ const msg =
+ 'ProgrammingError: If local values shall be included, a proper localFieldType argument has to be provided, too';
+ throw new Error(msg);
+ }
+ const result = {};
+
+ this.rbdConfigurationService.getWritableOptionFields().forEach((config) => {
+ const control: any = this.form.get('configuration').get(config.name);
+ const dirty = control.dirty;
+
+ if (this.initialData && this.initialData[config.name] === control.value) {
+ return; // Skip controls with initial data loaded
+ }
+
+ if (dirty || (includeLocalValues && control['source'] === localFieldType)) {
+ if (control.value === null) {
+ result[config.name] = control.value;
+ } else if (config.type === RbdConfigurationType.bps) {
+ result[config.name] = this.formatterService.toBytes(control.value);
+ } else if (config.type === RbdConfigurationType.milliseconds) {
+ result[config.name] = this.formatterService.toMilliseconds(control.value);
+ } else if (config.type === RbdConfigurationType.iops) {
+ result[config.name] = this.formatterService.toIops(control.value);
+ } else {
+ result[config.name] = control.value;
+ }
+ }
+ });
+
+ return result;
+ }
+
+ /**
+ * Dynamically create form controls.
+ */
+ private createConfigurationFormGroup() {
+ const configFormGroup = new CdFormGroup({});
+
+ this.rbdConfigurationService.getWritableOptionFields().forEach((c) => {
+ let control: FormControl;
+ if (
+ c.type === RbdConfigurationType.milliseconds ||
+ c.type === RbdConfigurationType.iops ||
+ c.type === RbdConfigurationType.bps
+ ) {
+ let initialValue = 0;
+ _.forEach(this.initialData, (configList) => {
+ if (configList['name'] === c.name) {
+ initialValue = configList['value'];
+ }
+ });
+ control = new FormControl(initialValue, Validators.min(0));
+ } else {
+ throw new Error(
+ `Type ${c.type} is unknown, you may need to add it to RbdConfiguration class`
+ );
+ }
+ configFormGroup.addControl(c.name, control);
+ });
+
+ return configFormGroup;
+ }
+
+ /**
+ * Reset the value. The inherited value will be used instead.
+ */
+ reset(optionName: string) {
+ const formControl: any = this.form.get('configuration').get(optionName);
+ if (formControl.disabled) {
+ formControl.setValue(formControl['previousValue'] || 0);
+ formControl.enable();
+ if (!formControl['previousValue']) {
+ formControl.markAsPristine();
+ }
+ } else {
+ formControl['previousValue'] = formControl.value;
+ formControl.setValue(null);
+ formControl.markAsDirty();
+ formControl.disable();
+ }
+ }
+
+ isDisabled(optionName: string) {
+ return this.form.get('configuration').get(optionName).disabled;
+ }
+
+ toggleSectionVisibility(className: string) {
+ this.sectionVisibility[className] = !this.sectionVisibility[className];
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html
new file mode 100644
index 000000000..6c3e8c027
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html
@@ -0,0 +1,29 @@
+<cd-table #poolConfTable
+ [data]="data"
+ [columns]="poolConfigurationColumns"
+ identifier="name">
+</cd-table>
+
+<ng-template #configurationSourceTpl
+ let-value="value">
+
+ <div [ngSwitch]="value">
+ <span *ngSwitchCase="'global'"
+ i18n>Global</span>
+ <strong *ngSwitchCase="'image'"
+ i18n>Image</strong>
+ <strong *ngSwitchCase="'pool'"
+ i18n>Pool</strong>
+ </div>
+</ng-template>
+
+<ng-template #configurationValueTpl
+ let-row="row"
+ let-value="value">
+ <div [ngSwitch]="row.type">
+ <span *ngSwitchCase="typeField.bps">{{ value | dimlessBinaryPerSecond }}</span>
+ <span *ngSwitchCase="typeField.milliseconds">{{ value | milliseconds }}</span>
+ <span *ngSwitchCase="typeField.iops">{{ value | iops }}</span>
+ <span *ngSwitchDefault>{{ value }}</span>
+ </div>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts
new file mode 100644
index 000000000..f54ad0272
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts
@@ -0,0 +1,99 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import { ChartsModule } from 'ng2-charts';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { RbdConfigurationEntry } from '~/app/shared/models/configuration';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { RbdConfigurationService } from '~/app/shared/services/rbd-configuration.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdConfigurationListComponent } from './rbd-configuration-list.component';
+
+describe('RbdConfigurationListComponent', () => {
+ let component: RbdConfigurationListComponent;
+ let fixture: ComponentFixture<RbdConfigurationListComponent>;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ FormsModule,
+ NgxDatatableModule,
+ RouterTestingModule,
+ ComponentsModule,
+ NgbDropdownModule,
+ ChartsModule,
+ SharedModule,
+ NgbTooltipModule
+ ],
+ declarations: [RbdConfigurationListComponent],
+ providers: [FormatterService, RbdConfigurationService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdConfigurationListComponent);
+ component = fixture.componentInstance;
+ component.data = [];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('filters options out which are not defined in RbdConfigurationService', () => {
+ const fakeOption = { name: 'foo', source: 0, value: '50' } as RbdConfigurationEntry;
+ const realOption = {
+ name: 'rbd_qos_read_iops_burst',
+ source: 0,
+ value: '50'
+ } as RbdConfigurationEntry;
+
+ component.data = [fakeOption, realOption];
+ component.ngOnChanges();
+
+ expect(component.data.length).toBe(1);
+ expect(component.data.pop()).toBe(realOption);
+ });
+
+ it('should filter the source column by its piped value', () => {
+ const poolConfTable = component.poolConfTable;
+ poolConfTable.data = [
+ {
+ name: 'rbd_qos_read_iops_burst',
+ source: 0,
+ value: '50'
+ },
+ {
+ name: 'rbd_qos_read_iops_limit',
+ source: 1,
+ value: '50'
+ },
+ {
+ name: 'rbd_qos_write_iops_limit',
+ source: 0,
+ value: '100'
+ },
+ {
+ name: 'rbd_qos_write_iops_burst',
+ source: 2,
+ value: '100'
+ }
+ ];
+ const filter = (keyword: string) => {
+ poolConfTable.search = keyword;
+ poolConfTable.updateFilter();
+ return poolConfTable.rows;
+ };
+ expect(filter('').length).toBe(4);
+ expect(filter('source:global').length).toBe(2);
+ expect(filter('source:pool').length).toBe(1);
+ expect(filter('source:image').length).toBe(1);
+ expect(filter('source:zero').length).toBe(0);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts
new file mode 100644
index 000000000..84fa02ff9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts
@@ -0,0 +1,65 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import {
+ RbdConfigurationEntry,
+ RbdConfigurationSourceField,
+ RbdConfigurationType
+} from '~/app/shared/models/configuration';
+import { RbdConfigurationSourcePipe } from '~/app/shared/pipes/rbd-configuration-source.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { RbdConfigurationService } from '~/app/shared/services/rbd-configuration.service';
+
+@Component({
+ selector: 'cd-rbd-configuration-table',
+ templateUrl: './rbd-configuration-list.component.html',
+ styleUrls: ['./rbd-configuration-list.component.scss']
+})
+export class RbdConfigurationListComponent implements OnInit, OnChanges {
+ @Input()
+ data: RbdConfigurationEntry[];
+ poolConfigurationColumns: CdTableColumn[];
+ @ViewChild('configurationSourceTpl', { static: true })
+ configurationSourceTpl: TemplateRef<any>;
+ @ViewChild('configurationValueTpl', { static: true })
+ configurationValueTpl: TemplateRef<any>;
+ @ViewChild('poolConfTable', { static: true })
+ poolConfTable: TableComponent;
+
+ readonly sourceField = RbdConfigurationSourceField;
+ readonly typeField = RbdConfigurationType;
+
+ constructor(
+ public formatterService: FormatterService,
+ private rbdConfigurationService: RbdConfigurationService
+ ) {}
+
+ ngOnInit() {
+ this.poolConfigurationColumns = [
+ { prop: 'displayName', name: $localize`Name` },
+ { prop: 'description', name: $localize`Description` },
+ { prop: 'name', name: $localize`Key` },
+ {
+ prop: 'source',
+ name: $localize`Source`,
+ cellTemplate: this.configurationSourceTpl,
+ pipe: new RbdConfigurationSourcePipe()
+ },
+ { prop: 'value', name: $localize`Value`, cellTemplate: this.configurationValueTpl }
+ ];
+ }
+
+ ngOnChanges(): void {
+ if (!this.data) {
+ return;
+ }
+ // Filter settings out which are not listed in RbdConfigurationService
+ this.data = this.data.filter((row) =>
+ this.rbdConfigurationService
+ .getOptionFields()
+ .map((o) => o.name)
+ .includes(row.name)
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
new file mode 100644
index 000000000..ab9454cbc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
@@ -0,0 +1,180 @@
+<ng-template #usageNotAvailableTooltipTpl>
+ <ng-container i18n>Only available for RBD images with <strong>fast-diff</strong> enabled</ng-container>
+</ng-template>
+
+<ng-container *ngIf="selection && selection.source !== 'REMOVING'">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="rbd-details">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Name</td>
+ <td class="w-75">{{ selection.name }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Pool</td>
+ <td>{{ selection.pool_name }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Data Pool</td>
+ <td>{{ selection.data_pool | empty }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Created</td>
+ <td>{{ selection.timestamp | cdDate }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Size</td>
+ <td>{{ selection.size | dimlessBinary }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Objects</td>
+ <td>{{ selection.num_objs | dimless }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Object size</td>
+ <td>{{ selection.obj_size | dimlessBinary }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Features</td>
+ <td>
+ <span *ngFor="let feature of selection.features_name">
+ <span class="badge badge-dark mr-2">{{ feature }}</span>
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Provisioned</td>
+ <td>
+ <span *ngIf="selection.features_name?.indexOf('fast-diff') === -1">
+ <span class="form-text text-muted"
+ [ngbTooltip]="usageNotAvailableTooltipTpl"
+ placement="top"
+ i18n>N/A</span>
+ </span>
+ <span *ngIf="selection.features_name?.indexOf('fast-diff') !== -1">
+ {{ selection.disk_usage | dimlessBinary }}
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Total provisioned</td>
+ <td>
+ <span *ngIf="selection.features_name?.indexOf('fast-diff') === -1">
+ <span class="form-text text-muted"
+ [ngbTooltip]="usageNotAvailableTooltipTpl"
+ placement="top"
+ i18n>N/A</span>
+ </span>
+ <span *ngIf="selection.features_name?.indexOf('fast-diff') !== -1">
+ {{ selection.total_disk_usage | dimlessBinary }}
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Striping unit</td>
+ <td>{{ selection.stripe_unit | dimlessBinary }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Striping count</td>
+ <td>{{ selection.stripe_count }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Parent</td>
+ <td>
+ <span *ngIf="selection.parent">{{ selection.parent.pool_name }}<span
+ *ngIf="selection.parent.pool_namespace">/{{ selection.parent.pool_namespace }}</span>/{{ selection.parent.image_name }}@{{ selection.parent.snap_name }}</span>
+ <span *ngIf="!selection.parent">-</span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Block name prefix</td>
+ <td>{{ selection.block_name_prefix }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Order</td>
+ <td>{{ selection.order }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Format Version</td>
+ <td>{{ selection.image_format }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </ng-template>
+ </li>
+ <li ngbNavItem="snapshots">
+ <a ngbNavLink
+ i18n>Snapshots</a>
+ <ng-template ngbNavContent>
+ <cd-rbd-snapshot-list [snapshots]="selection.snapshots"
+ [featuresName]="selection.features_name"
+ [poolName]="selection.pool_name"
+ [namespace]="selection.namespace"
+ [mirroring]="selection.mirror_mode"
+ [rbdName]="selection.name"></cd-rbd-snapshot-list>
+ </ng-template>
+ </li>
+ <li ngbNavItem="configuration">
+ <a ngbNavLink
+ i18n>Configuration</a>
+ <ng-template ngbNavContent>
+ <cd-rbd-configuration-table [data]="selection['configuration']"></cd-rbd-configuration-table>
+ </ng-template>
+ </li>
+
+ <li ngbNavItem="performance">
+ <a ngbNavLink
+ i18n>Performance</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="rbdDashboardUrl"
+ uid="YhCYGcuZz"
+ grafanaStyle="one">
+ </cd-grafana>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
+<ng-container *ngIf="selection && selection.source === 'REMOVING'">
+ <cd-alert-panel type="warning"
+ i18n>Information can not be displayed for RBD in status 'Removing'.</cd-alert-panel>
+</ng-container>
+
+<ng-template #poolConfigurationSourceTpl
+ let-row="row"
+ let-value="value">
+ <ng-container *ngIf="+value; else global">
+ <strong i18n
+ i18n-ngbTooltip
+ ngbTooltip="This setting overrides the global value">Image</strong>
+ </ng-container>
+ <ng-template #global>
+ <span i18n
+ i18n-ngbTooltip
+ ngbTooltip="This is the global value. No value for this option has been set for this image.">Global</span>
+ </ng-template>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts
new file mode 100644
index 000000000..757976546
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component';
+import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdDetailsComponent } from './rbd-details.component';
+
+describe('RbdDetailsComponent', () => {
+ let component: RbdDetailsComponent;
+ let fixture: ComponentFixture<RbdDetailsComponent>;
+
+ configureTestBed({
+ declarations: [RbdDetailsComponent, RbdSnapshotListComponent, RbdConfigurationListComponent],
+ imports: [SharedModule, NgbTooltipModule, RouterTestingModule, NgbNavModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts
new file mode 100644
index 000000000..ee06198d1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts
@@ -0,0 +1,31 @@
+import { Component, Input, OnChanges, TemplateRef, ViewChild } from '@angular/core';
+
+import { NgbNav } from '@ng-bootstrap/ng-bootstrap';
+
+import { RbdFormModel } from '../rbd-form/rbd-form.model';
+
+@Component({
+ selector: 'cd-rbd-details',
+ templateUrl: './rbd-details.component.html',
+ styleUrls: ['./rbd-details.component.scss']
+})
+export class RbdDetailsComponent implements OnChanges {
+ @Input()
+ selection: RbdFormModel;
+ @Input()
+ images: any;
+
+ @ViewChild('poolConfigurationSourceTpl', { static: true })
+ poolConfigurationSourceTpl: TemplateRef<any>;
+
+ @ViewChild(NgbNav, { static: true })
+ nav: NgbNav;
+
+ rbdDashboardUrl: string;
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.rbdDashboardUrl = `rbd-details?var-Pool=${this.selection['pool_name']}&var-Image=${this.selection['name']}`;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-feature.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-feature.interface.ts
new file mode 100644
index 000000000..c12975f05
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-feature.interface.ts
@@ -0,0 +1,9 @@
+export interface RbdImageFeature {
+ desc: string;
+ allowEnable: boolean;
+ allowDisable: boolean;
+ requires?: string;
+ interlockedWith?: string;
+ key?: string;
+ initDisabled?: boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts
new file mode 100644
index 000000000..fa190c0d5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts
@@ -0,0 +1,13 @@
+import { RbdConfigurationEntry } from '~/app/shared/models/configuration';
+
+export class RbdFormCloneRequestModel {
+ child_pool_name: string;
+ child_namespace: string;
+ child_image_name: string;
+ obj_size: number;
+ features: Array<string> = [];
+ stripe_unit: number;
+ stripe_count: number;
+ data_pool: string;
+ configuration?: RbdConfigurationEntry[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts
new file mode 100644
index 000000000..af86234af
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts
@@ -0,0 +1,14 @@
+import { RbdConfigurationEntry } from '~/app/shared/models/configuration';
+
+export class RbdFormCopyRequestModel {
+ dest_pool_name: string;
+ dest_namespace: string;
+ dest_image_name: string;
+ snapshot_name: string;
+ obj_size: number;
+ features: Array<string> = [];
+ stripe_unit: number;
+ stripe_count: number;
+ data_pool: string;
+ configuration: RbdConfigurationEntry[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts
new file mode 100644
index 000000000..2a2366f7c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts
@@ -0,0 +1,5 @@
+import { RbdFormModel } from './rbd-form.model';
+
+export class RbdFormCreateRequestModel extends RbdFormModel {
+ features: Array<string> = [];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts
new file mode 100644
index 000000000..8b994d958
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts
@@ -0,0 +1,14 @@
+import { RbdConfigurationEntry } from '~/app/shared/models/configuration';
+
+export class RbdFormEditRequestModel {
+ name: string;
+ size: number;
+ features: Array<string> = [];
+ configuration: RbdConfigurationEntry[];
+
+ enable_mirror?: boolean;
+ mirror_mode?: string;
+ primary?: boolean;
+ schedule_interval: string;
+ remove_scheduling? = false;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-mode.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-mode.enum.ts
new file mode 100644
index 000000000..3db18a1d6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-mode.enum.ts
@@ -0,0 +1,5 @@
+export enum RbdFormMode {
+ editing = 'editing',
+ cloning = 'cloning',
+ copying = 'copying'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts
new file mode 100644
index 000000000..7468e3a2b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts
@@ -0,0 +1,7 @@
+import { RbdFormModel } from './rbd-form.model';
+import { RbdParentModel } from './rbd-parent.model';
+
+export class RbdFormResponseModel extends RbdFormModel {
+ features_name: string[];
+ parent: RbdParentModel;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html
new file mode 100644
index 000000000..38f204762
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html
@@ -0,0 +1,395 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="rbdForm"
+ #formDir="ngForm"
+ [formGroup]="rbdForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+ <div class="card-body">
+
+ <!-- Parent -->
+ <div class="form-group row"
+ *ngIf="rbdForm.getValue('parent')">
+ <label i18n
+ class="cd-col-form-label"
+ for="name">{{ action | titlecase }} from</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ id="parent"
+ name="parent"
+ formControlName="parent">
+ <hr>
+ </div>
+ </div>
+
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="name"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Name..."
+ id="name"
+ name="name"
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('name', formDir, 'required')">
+ <ng-container i18n>This field is required.</ng-container>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('name', formDir, 'pattern')">
+ <ng-container i18n>'/' and '@' are not allowed.</ng-container>
+ </span>
+ </div>
+ </div>
+
+ <!-- Pool -->
+ <div class="form-group row"
+ (change)="onPoolChange($event.target.value)">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': mode !== 'editing'}"
+ for="pool"
+ i18n>Pool</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Pool name..."
+ id="pool"
+ name="pool"
+ formControlName="pool"
+ *ngIf="mode === 'editing' || !poolPermission.read">
+ <select id="pool"
+ name="pool"
+ class="form-control"
+ formControlName="pool"
+ *ngIf="mode !== 'editing' && poolPermission.read"
+ (change)="setPoolMirrorMode()">
+ <option *ngIf="pools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="pools !== null && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No rbd pools available --</option>
+ <option *ngIf="pools !== null && pools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a pool --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </select>
+ <span *ngIf="rbdForm.showError('pool', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Namespace -->
+ <div class="form-group row"
+ *ngIf="mode !== 'editing' && rbdForm.getValue('pool') && namespaces === null">
+ <div class="cd-col-form-offset">
+ <i [ngClass]="[icons.spinner, icons.spin]"></i>
+ </div>
+ </div>
+ <div class="form-group row"
+ *ngIf="(mode === 'editing' && rbdForm.getValue('namespace')) || mode !== 'editing' && (namespaces && namespaces.length > 0 || !poolPermission.read)">
+ <label class="cd-col-form-label"
+ for="pool">
+ Namespace
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Namespace..."
+ id="namespace"
+ name="namespace"
+ formControlName="namespace"
+ *ngIf="mode === 'editing' || !poolPermission.read">
+ <select id="namespace"
+ name="namespace"
+ class="form-control"
+ formControlName="namespace"
+ *ngIf="mode !== 'editing' && poolPermission.read">
+ <option *ngIf="pools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="pools !== null && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No namespaces available --</option>
+ <option *ngIf="pools !== null && pools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a namespace --</option>
+ <option *ngFor="let namespace of namespaces"
+ [value]="namespace">{{ namespace }}</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Use a dedicated pool -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="useDataPool"
+ name="useDataPool"
+ formControlName="useDataPool"
+ (change)="onUseDataPoolChange()">
+ <label class="custom-control-label"
+ for="useDataPool"
+ i18n>Use a dedicated data pool</label>
+ <cd-helper *ngIf="allDataPools.length <= 1">
+ <span i18n>You need more than one pool with the rbd application label use to use a dedicated data pool.</span>
+ </cd-helper>
+ </div>
+ </div>
+ </div>
+
+ <!-- Data Pool -->
+ <div class="form-group row"
+ *ngIf="rbdForm.getValue('useDataPool')">
+ <label class="cd-col-form-label"
+ for="dataPool">
+ <span [ngClass]="{'required': mode !== 'editing'}"
+ i18n>Data pool</span>
+ <cd-helper i18n-html
+ html="Dedicated pool that stores the object-data of the RBD.">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Data pool name..."
+ id="dataPool"
+ name="dataPool"
+ formControlName="dataPool"
+ *ngIf="mode === 'editing' || !poolPermission.read">
+ <select id="dataPool"
+ name="dataPool"
+ class="form-control"
+ formControlName="dataPool"
+ (change)="onDataPoolChange($event.target.value)"
+ *ngIf="mode !== 'editing' && poolPermission.read">
+ <option *ngIf="dataPools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="dataPools !== null && dataPools.length === 0"
+ [ngValue]="null"
+ i18n>-- No data pools available --</option>
+ <option *ngIf="dataPools !== null && dataPools.length > 0"
+ [ngValue]="null">-- Select a data pool --
+ </option>
+ <option *ngFor="let dataPool of dataPools"
+ [value]="dataPool.pool_name">{{ dataPool.pool_name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('dataPool', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Size -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="size"
+ i18n>Size</label>
+ <div class="cd-col-form-input">
+ <input id="size"
+ name="size"
+ class="form-control"
+ type="text"
+ formControlName="size"
+ i18n-placeholder
+ placeholder="e.g., 10GiB"
+ defaultUnit="GiB"
+ cdDimlessBinary>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('size', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('size', formDir, 'invalidSizeObject')"
+ i18n>You have to increase the size.</span>
+ </div>
+ </div>
+
+ <!-- Features -->
+ <div class="form-group row"
+ formGroupName="features">
+ <label i18n
+ class="cd-col-form-label"
+ for="features">Features</label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox"
+ *ngFor="let feature of featuresList">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="{{ feature.key }}"
+ name="{{ feature.key }}"
+ formControlName="{{ feature.key }}">
+ <label class="custom-control-label"
+ for="{{ feature.key }}">{{ feature.desc }}</label>
+ <cd-helper *ngIf="feature.helperHtml"
+ html="{{ feature.helperHtml }}">
+ </cd-helper>
+ </div>
+ </div>
+ </div>
+
+ <!-- Mirroring -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="mirroring"
+ name="mirroring"
+ (change)="setMirrorMode()"
+ formControlName="mirroring">
+ <label class="custom-control-label"
+ for="mirroring">Mirroring</label>
+ <cd-helper *ngIf="mirroring === false && this.currentPoolName">
+ <span i18n>You need to enable a <b>mirror mode</b> in the selected pool. Please <a [routerLink]="['/block/mirroring', {outlets: {modal: ['edit', currentPoolName]}}]">click here to select a mode and enable it in this pool.</a></span>
+ </cd-helper>
+ </div>
+ <div *ngIf="mirroring">
+ <div class="custom-control custom-radio ml-2"
+ *ngFor="let option of mirroringOptions">
+ <input type="radio"
+ class="custom-control-input"
+ [id]="option"
+ [value]="option"
+ name="mirroringMode"
+ (change)="setExclusiveLock()"
+ formControlName="mirroringMode"
+ [attr.disabled]="(poolMirrorMode === 'pool' && option === 'snapshot') ? true : null">
+ <label class="custom-control-label"
+ [for]="option">{{ option | titlecase }}</label>
+ <cd-helper *ngIf="poolMirrorMode === 'pool' && option === 'snapshot'">
+ <span i18n>You need to enable <b>image mirror mode</b> in the selected pool. Please <a [routerLink]="['/block/mirroring', {outlets: {modal: ['edit', currentPoolName]}}]">click here to select a mode and enable it in this pool.</a></span>
+ </cd-helper>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="rbdForm.getValue('mirroringMode') === 'snapshot' && mirroring">
+ <label class="cd-col-form-label"
+ i18n>Schedule Interval
+ <cd-helper i18n-html
+ html="Create Mirror-Snapshots automatically on a periodic basis. The interval can be specified in days, hours, or minutes using d, h, m suffix respectively.">
+ </cd-helper></label>
+ <div class="cd-col-form-input">
+ <input id="schedule"
+ name="schedule"
+ class="form-control"
+ type="text"
+ formControlName="schedule"
+ i18n-placeholder
+ placeholder="e.g., 12h or 1d or 10m"
+ [attr.disabled]="(mode === rbdFormMode.editing) ? true : null">
+ </div>
+ </div>
+
+ <!-- Advanced -->
+ <div class="row">
+ <div class="col-sm-12">
+ <a class="float-right margin-right-md"
+ (click)="advancedEnabled = true; false"
+ *ngIf="!advancedEnabled"
+ href=""
+ i18n>Advanced...</a>
+ </div>
+ </div>
+
+ <div [hidden]="!advancedEnabled">
+
+ <legend class="cd-header"
+ i18n>Advanced</legend>
+
+ <div class="col-md-12">
+ <h4 class="cd-header"
+ i18n>Striping</h4>
+
+ <!-- Object Size -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="size">Object size<cd-helper>Objects in the Ceph Storage Cluster have a maximum configurable size (e.g., 2MB, 4MB, etc.). The object size should be large enough to accommodate many stripe units, and should be a multiple of the stripe unit.</cd-helper></label>
+ <div class="cd-col-form-input">
+ <select id="obj_size"
+ name="obj_size"
+ class="form-control"
+ formControlName="obj_size">
+ <option *ngFor="let objectSize of objectSizes"
+ [value]="objectSize">{{ objectSize }}</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- stripingUnit -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': rbdForm.getValue('stripingCount')}"
+ for="stripingUnit"
+ i18n>Stripe unit<cd-helper>Stripes have a configurable unit size (e.g., 64kb). The Ceph Client divides the data it will write to objects into equally sized stripe units, except for the last stripe unit. A stripe width, should be a fraction of the Object Size so that an object may contain many stripe units.</cd-helper></label>
+ <div class="cd-col-form-input">
+ <select id="stripingUnit"
+ name="stripingUnit"
+ class="form-control"
+ formControlName="stripingUnit">
+ <option i18n
+ [ngValue]="null">-- Select stripe unit --</option>
+ <option *ngFor="let objectSize of objectSizes"
+ [value]="objectSize">{{ objectSize }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('stripingUnit', formDir, 'required')"
+ i18n>This field is required because stripe count is defined!</span>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('stripingUnit', formDir, 'invalidStripingUnit')"
+ i18n>Stripe unit is greater than object size.</span>
+ </div>
+ </div>
+
+ <!-- Stripe Count -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': rbdForm.getValue('stripingUnit')}"
+ for="stripingCount"
+ i18n>Stripe count<cd-helper>The Ceph Client writes a sequence of stripe units over a series of objects determined by the stripe count. The series of objects is called an object set. After the Ceph Client writes to the last object in the object set, it returns to the first object in the object set.</cd-helper></label>
+ <div class="cd-col-form-input">
+ <input id="stripingCount"
+ name="stripingCount"
+ formControlName="stripingCount"
+ class="form-control"
+ type="number">
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('stripingCount', formDir, 'required')"
+ i18n>This field is required because stripe unit is defined!</span>
+ <span class="invalid-feedback"
+ *ngIf="rbdForm.showError('stripingCount', formDir, 'min')"
+ i18n>Stripe count must be greater than 0.</span>
+ </div>
+ </div>
+ </div>
+
+ <cd-rbd-configuration-form [form]="rbdForm"
+ [initializeData]="initializeConfigData"
+ (changes)="getDirtyConfigurationValues = $event"></cd-rbd-configuration-form>
+ </div>
+
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="formDir"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts
new file mode 100644
index 000000000..7f7815c00
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts
@@ -0,0 +1,480 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { NEVER, of } from 'rxjs';
+import { delay } from 'rxjs/operators';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdConfigurationFormComponent } from '../rbd-configuration-form/rbd-configuration-form.component';
+import { RbdImageFeature } from './rbd-feature.interface';
+import { RbdFormMode } from './rbd-form-mode.enum';
+import { RbdFormResponseModel } from './rbd-form-response.model';
+import { RbdFormComponent } from './rbd-form.component';
+
+describe('RbdFormComponent', () => {
+ const urlPrefix = {
+ create: '/block/rbd/create',
+ edit: '/block/rbd/edit',
+ clone: '/block/rbd/clone',
+ copy: '/block/rbd/copy'
+ };
+ let component: RbdFormComponent;
+ let fixture: ComponentFixture<RbdFormComponent>;
+ let activatedRoute: ActivatedRouteStub;
+ const mock: { rbd: RbdFormResponseModel; pools: Pool[]; defaultFeatures: string[] } = {
+ rbd: {} as RbdFormResponseModel,
+ pools: [],
+ defaultFeatures: []
+ };
+
+ const setRouterUrl = (
+ action: 'create' | 'edit' | 'clone' | 'copy',
+ poolName?: string,
+ imageName?: string
+ ) => {
+ component['routerUrl'] = [urlPrefix[action], poolName, imageName].filter((x) => x).join('/');
+ };
+
+ const queryNativeElement = (cssSelector: string) =>
+ fixture.debugElement.query(By.css(cssSelector)).nativeElement;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ SharedModule
+ ],
+ declarations: [RbdFormComponent, RbdConfigurationFormComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: new ActivatedRouteStub({ pool: 'foo', name: 'bar', snap: undefined })
+ },
+ RbdService
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdFormComponent);
+ component = fixture.componentInstance;
+ activatedRoute = <ActivatedRouteStub>TestBed.inject(ActivatedRoute);
+
+ component.loadingReady();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('create/edit/clone/copy image', () => {
+ let createAction: jasmine.Spy;
+ let editAction: jasmine.Spy;
+ let cloneAction: jasmine.Spy;
+ let copyAction: jasmine.Spy;
+ let rbdServiceGetSpy: jasmine.Spy;
+ let routerNavigate: jasmine.Spy;
+
+ const DELAY = 100;
+
+ const getPool = (
+ pool_name: string,
+ type: 'replicated' | 'erasure',
+ flags_names: string,
+ application_metadata: string[]
+ ): Pool =>
+ ({
+ pool_name,
+ flags_names,
+ application_metadata,
+ type
+ } as Pool);
+
+ beforeEach(() => {
+ createAction = spyOn(component, 'createAction').and.returnValue(of(null));
+ editAction = spyOn(component, 'editAction');
+ editAction.and.returnValue(of(null));
+ cloneAction = spyOn(component, 'cloneAction').and.returnValue(of(null));
+ copyAction = spyOn(component, 'copyAction').and.returnValue(of(null));
+ spyOn(component, 'setResponse').and.stub();
+ routerNavigate = spyOn(TestBed.inject(Router), 'navigate').and.stub();
+ mock.pools = [
+ getPool('one', 'replicated', '', []),
+ getPool('two', 'replicated', '', ['rbd']),
+ getPool('three', 'replicated', '', ['rbd']),
+ getPool('four', 'erasure', '', ['rbd']),
+ getPool('four', 'erasure', 'ec_overwrites', ['rbd'])
+ ];
+ spyOn(TestBed.inject(PoolService), 'list').and.callFake(() => of(mock.pools));
+ rbdServiceGetSpy = spyOn(TestBed.inject(RbdService), 'get');
+ mock.rbd = ({ pool_name: 'foo', pool_image: 'bar' } as any) as RbdFormResponseModel;
+ rbdServiceGetSpy.and.returnValue(of(mock.rbd));
+ component.mode = undefined;
+ });
+
+ it('should create image', () => {
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(1);
+ expect(editAction).toHaveBeenCalledTimes(0);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('should unsubscribe right after image data is received', () => {
+ setRouterUrl('edit', 'foo', 'bar');
+ rbdServiceGetSpy.and.returnValue(of(mock.rbd));
+ editAction.and.returnValue(NEVER);
+ expect(component['rbdImage'].observers.length).toEqual(0);
+ component.ngOnInit(); // Subscribes to image once during init
+ component.submit();
+ expect(component['rbdImage'].observers.length).toEqual(1);
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(1);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not edit image if no image data is received', fakeAsync(() => {
+ setRouterUrl('edit', 'foo', 'bar');
+ rbdServiceGetSpy.and.returnValue(of(mock.rbd).pipe(delay(DELAY)));
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(0);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(0);
+
+ tick(DELAY);
+ }));
+
+ describe('disable data pools', () => {
+ beforeEach(() => {
+ component.ngOnInit();
+ });
+
+ it('should be enabled with more than 1 pool', () => {
+ component['handleExternalData'](mock);
+ expect(component.allDataPools.length).toBe(3);
+ expect(component.rbdForm.get('useDataPool').disabled).toBe(false);
+
+ mock.pools.pop();
+ component['handleExternalData'](mock);
+ expect(component.allDataPools.length).toBe(2);
+ expect(component.rbdForm.get('useDataPool').disabled).toBe(false);
+ });
+
+ it('should be disabled with 1 pool', () => {
+ mock.pools = [mock.pools[0]];
+ component['handleExternalData'](mock);
+ expect(component.rbdForm.get('useDataPool').disabled).toBe(true);
+ });
+
+ // Reason for 2 tests - useDataPool is not re-enabled anywhere else
+ it('should be disabled without any pool', () => {
+ mock.pools = [];
+ component['handleExternalData'](mock);
+ expect(component.rbdForm.get('useDataPool').disabled).toBe(true);
+ });
+ });
+
+ it('should edit image after image data is received', () => {
+ setRouterUrl('edit', 'foo', 'bar');
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(1);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not clone image if no image data is received', fakeAsync(() => {
+ setRouterUrl('clone', 'foo', 'bar');
+ rbdServiceGetSpy.and.returnValue(of(mock.rbd).pipe(delay(DELAY)));
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(0);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(0);
+
+ tick(DELAY);
+ }));
+
+ it('should clone image after image data is received', () => {
+ setRouterUrl('clone', 'foo', 'bar');
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(0);
+ expect(cloneAction).toHaveBeenCalledTimes(1);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not copy image if no image data is received', fakeAsync(() => {
+ setRouterUrl('copy', 'foo', 'bar');
+ rbdServiceGetSpy.and.returnValue(of(mock.rbd).pipe(delay(DELAY)));
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(0);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(0);
+ expect(routerNavigate).toHaveBeenCalledTimes(0);
+
+ tick(DELAY);
+ }));
+
+ it('should copy image after image data is received', () => {
+ setRouterUrl('copy', 'foo', 'bar');
+ component.ngOnInit();
+ component.submit();
+
+ expect(createAction).toHaveBeenCalledTimes(0);
+ expect(editAction).toHaveBeenCalledTimes(0);
+ expect(cloneAction).toHaveBeenCalledTimes(0);
+ expect(copyAction).toHaveBeenCalledTimes(1);
+ expect(routerNavigate).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('should test decodeURIComponent of params', () => {
+ let rbdService: RbdService;
+
+ beforeEach(() => {
+ rbdService = TestBed.inject(RbdService);
+ component.mode = RbdFormMode.editing;
+ fixture.detectChanges();
+ spyOn(rbdService, 'get').and.callThrough();
+ });
+
+ it('with namespace', () => {
+ activatedRoute.setParams({ image_spec: 'foo%2Fbar%2Fbaz' });
+
+ expect(rbdService.get).toHaveBeenCalledWith(new ImageSpec('foo', 'bar', 'baz'));
+ });
+
+ it('without snapName', () => {
+ activatedRoute.setParams({ image_spec: 'foo%2Fbar', snap: undefined });
+
+ expect(rbdService.get).toHaveBeenCalledWith(new ImageSpec('foo', null, 'bar'));
+ expect(component.snapName).toBeUndefined();
+ });
+
+ it('with snapName', () => {
+ activatedRoute.setParams({ image_spec: 'foo%2Fbar', snap: 'baz%2Fbaz' });
+
+ expect(rbdService.get).toHaveBeenCalledWith(new ImageSpec('foo', null, 'bar'));
+ expect(component.snapName).toBe('baz/baz');
+ });
+ });
+
+ describe('test image configuration component', () => {
+ it('is visible', () => {
+ fixture.detectChanges();
+ expect(
+ fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
+ .hidden
+ ).toBe(true);
+ });
+ });
+
+ describe('tests for feature flags', () => {
+ let deepFlatten: any, layering: any, exclusiveLock: any, objectMap: any, fastDiff: any;
+ const defaultFeatures = [
+ // Supposed to be enabled by default
+ 'deep-flatten',
+ 'exclusive-lock',
+ 'fast-diff',
+ 'layering',
+ 'object-map'
+ ];
+ const allFeatureNames = [
+ 'deep-flatten',
+ 'layering',
+ 'exclusive-lock',
+ 'object-map',
+ 'fast-diff'
+ ];
+ const setFeatures = (features: Record<string, RbdImageFeature>) => {
+ component.features = features;
+ component.featuresList = component.objToArray(features);
+ component.createForm();
+ };
+ const getFeatureNativeElements = () => allFeatureNames.map((f) => queryNativeElement(`#${f}`));
+
+ it('should convert feature flags correctly in the constructor', () => {
+ setFeatures({
+ one: { desc: 'one', allowEnable: true, allowDisable: true },
+ two: { desc: 'two', allowEnable: true, allowDisable: true },
+ three: { desc: 'three', allowEnable: true, allowDisable: true }
+ });
+ expect(component.featuresList).toEqual([
+ { desc: 'one', key: 'one', allowDisable: true, allowEnable: true },
+ { desc: 'two', key: 'two', allowDisable: true, allowEnable: true },
+ { desc: 'three', key: 'three', allowDisable: true, allowEnable: true }
+ ]);
+ });
+
+ describe('test edit form flags', () => {
+ const prepare = (pool: string, image: string, enabledFeatures: string[]): void => {
+ const rbdService = TestBed.inject(RbdService);
+ spyOn(rbdService, 'get').and.returnValue(
+ of({
+ name: image,
+ pool_name: pool,
+ features_name: enabledFeatures
+ })
+ );
+ spyOn(rbdService, 'defaultFeatures').and.returnValue(of(defaultFeatures));
+ setRouterUrl('edit', pool, image);
+ fixture.detectChanges();
+ [deepFlatten, layering, exclusiveLock, objectMap, fastDiff] = getFeatureNativeElements();
+ };
+
+ it('should have the interlock feature for flags disabled, if one feature is not set', () => {
+ prepare('rbd', 'foobar', ['deep-flatten', 'exclusive-lock', 'layering', 'object-map']);
+
+ expect(objectMap.disabled).toBe(false);
+ expect(fastDiff.disabled).toBe(false);
+
+ expect(objectMap.checked).toBe(true);
+ expect(fastDiff.checked).toBe(false);
+
+ fastDiff.click();
+ fastDiff.click();
+
+ expect(objectMap.checked).toBe(true); // Shall not be disabled by `fast-diff`!
+ });
+
+ it('should not disable object-map when fast-diff is unchecked', () => {
+ prepare('rbd', 'foobar', ['deep-flatten', 'exclusive-lock', 'layering', 'object-map']);
+
+ fastDiff.click();
+ fastDiff.click();
+
+ expect(objectMap.checked).toBe(true); // Shall not be disabled by `fast-diff`!
+ });
+
+ it('should not enable fast-diff when object-map is checked', () => {
+ prepare('rbd', 'foobar', ['deep-flatten', 'exclusive-lock', 'layering', 'object-map']);
+
+ objectMap.click();
+ objectMap.click();
+
+ expect(fastDiff.checked).toBe(false); // Shall not be disabled by `fast-diff`!
+ });
+ });
+
+ describe('test create form flags', () => {
+ beforeEach(() => {
+ const rbdService = TestBed.inject(RbdService);
+ spyOn(rbdService, 'defaultFeatures').and.returnValue(of(defaultFeatures));
+ setRouterUrl('create');
+ fixture.detectChanges();
+ [deepFlatten, layering, exclusiveLock, objectMap, fastDiff] = getFeatureNativeElements();
+ });
+
+ it('should initialize the checkboxes correctly', () => {
+ expect(deepFlatten.disabled).toBe(false);
+ expect(layering.disabled).toBe(false);
+ expect(exclusiveLock.disabled).toBe(false);
+ expect(objectMap.disabled).toBe(false);
+ expect(fastDiff.disabled).toBe(false);
+
+ expect(deepFlatten.checked).toBe(true);
+ expect(layering.checked).toBe(true);
+ expect(exclusiveLock.checked).toBe(true);
+ expect(objectMap.checked).toBe(true);
+ expect(fastDiff.checked).toBe(true);
+ });
+
+ it('should disable features if their requirements are not met (exclusive-lock)', () => {
+ exclusiveLock.click(); // unchecks exclusive-lock
+ expect(objectMap.disabled).toBe(true);
+ expect(fastDiff.disabled).toBe(true);
+ });
+
+ it('should disable features if their requirements are not met (object-map)', () => {
+ objectMap.click(); // unchecks object-map
+ expect(fastDiff.disabled).toBe(true);
+ });
+ });
+
+ describe('test mirroring options', () => {
+ beforeEach(() => {
+ component.ngOnInit();
+ fixture.detectChanges();
+ const mirroring = fixture.debugElement.query(By.css('#mirroring')).nativeElement;
+ mirroring.click();
+ fixture.detectChanges();
+ });
+
+ it('should verify two mirroring options are shown', () => {
+ const journal = fixture.debugElement.query(By.css('#journal')).nativeElement;
+ const snapshot = fixture.debugElement.query(By.css('#snapshot')).nativeElement;
+ expect(journal).not.toBeNull();
+ expect(snapshot).not.toBeNull();
+ });
+
+ it('should verify only snapshot is disabled for pools that are in pool mirror mode', () => {
+ component.poolMirrorMode = 'pool';
+ fixture.detectChanges();
+ const journal = fixture.debugElement.query(By.css('#journal')).nativeElement;
+ const snapshot = fixture.debugElement.query(By.css('#snapshot')).nativeElement;
+ expect(journal.disabled).toBe(false);
+ expect(snapshot.disabled).toBe(true);
+ });
+
+ it('should set and disable exclusive-lock only for the journal mode', () => {
+ component.poolMirrorMode = 'pool';
+ fixture.detectChanges();
+ const exclusiveLocks = fixture.debugElement.query(By.css('#exclusive-lock')).nativeElement;
+ expect(exclusiveLocks.checked).toBe(true);
+ expect(exclusiveLocks.disabled).toBe(true);
+ });
+
+ it('should have journaling feature for journaling mirror mode on createRequest', () => {
+ component.mirroring = true;
+ fixture.detectChanges();
+ const journal = fixture.debugElement.query(By.css('#journal')).nativeElement;
+ expect(journal.checked).toBe(true);
+ const request = component.createRequest();
+ expect(request.features).toContain('journaling');
+ });
+
+ it('should have journaling feature for journaling mirror mode on editRequest', () => {
+ component.mirroring = true;
+ fixture.detectChanges();
+ const journal = fixture.debugElement.query(By.css('#journal')).nativeElement;
+ expect(journal.checked).toBe(true);
+ const request = component.editRequest();
+ expect(request.features).toContain('journaling');
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts
new file mode 100644
index 000000000..f6c6af579
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts
@@ -0,0 +1,815 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, ValidatorFn, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import { forkJoin, Observable, ReplaySubject } from 'rxjs';
+import { first, switchMap } from 'rxjs/operators';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdMirroringService } from '~/app/shared/api/rbd-mirroring.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import {
+ RbdConfigurationEntry,
+ RbdConfigurationSourceField
+} from '~/app/shared/models/configuration';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { Permission } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { RBDImageFormat, RbdModel } from '../rbd-list/rbd-model';
+import { RbdImageFeature } from './rbd-feature.interface';
+import { RbdFormCloneRequestModel } from './rbd-form-clone-request.model';
+import { RbdFormCopyRequestModel } from './rbd-form-copy-request.model';
+import { RbdFormCreateRequestModel } from './rbd-form-create-request.model';
+import { RbdFormEditRequestModel } from './rbd-form-edit-request.model';
+import { RbdFormMode } from './rbd-form-mode.enum';
+import { RbdFormResponseModel } from './rbd-form-response.model';
+
+class ExternalData {
+ rbd: RbdFormResponseModel;
+ defaultFeatures: string[];
+ pools: Pool[];
+}
+
+@Component({
+ selector: 'cd-rbd-form',
+ templateUrl: './rbd-form.component.html',
+ styleUrls: ['./rbd-form.component.scss']
+})
+export class RbdFormComponent extends CdForm implements OnInit {
+ poolPermission: Permission;
+ rbdForm: CdFormGroup;
+ getDirtyConfigurationValues: (
+ includeLocalField?: boolean,
+ localField?: RbdConfigurationSourceField
+ ) => RbdConfigurationEntry[];
+
+ namespaces: Array<string> = [];
+ namespacesByPoolCache = {};
+ pools: Array<Pool> = null;
+ allPools: Array<Pool> = null;
+ dataPools: Array<Pool> = null;
+ allDataPools: Array<Pool> = [];
+ features: { [key: string]: RbdImageFeature };
+ featuresList: RbdImageFeature[] = [];
+ initializeConfigData = new ReplaySubject<{
+ initialData: RbdConfigurationEntry[];
+ sourceType: RbdConfigurationSourceField;
+ }>(1);
+
+ pool: string;
+
+ advancedEnabled = false;
+
+ public rbdFormMode = RbdFormMode;
+ mode: RbdFormMode;
+
+ response: RbdFormResponseModel;
+ snapName: string;
+
+ defaultObjectSize = '4 MiB';
+
+ mirroringOptions = ['journal', 'snapshot'];
+ poolMirrorMode: string;
+ mirroring = false;
+ currentPoolName = '';
+
+ objectSizes: Array<string> = [
+ '4 KiB',
+ '8 KiB',
+ '16 KiB',
+ '32 KiB',
+ '64 KiB',
+ '128 KiB',
+ '256 KiB',
+ '512 KiB',
+ '1 MiB',
+ '2 MiB',
+ '4 MiB',
+ '8 MiB',
+ '16 MiB',
+ '32 MiB'
+ ];
+
+ defaultStripingUnit = '4 MiB';
+
+ defaultStripingCount = 1;
+
+ action: string;
+ resource: string;
+ private rbdImage = new ReplaySubject(1);
+ private routerUrl: string;
+
+ icons = Icons;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private route: ActivatedRoute,
+ private poolService: PoolService,
+ private rbdService: RbdService,
+ private formatter: FormatterService,
+ private taskWrapper: TaskWrapperService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ public actionLabels: ActionLabelsI18n,
+ private router: Router,
+ private rbdMirroringService: RbdMirroringService
+ ) {
+ super();
+ this.routerUrl = this.router.url;
+ this.poolPermission = this.authStorageService.getPermissions().pool;
+ this.resource = $localize`RBD`;
+ this.features = {
+ 'deep-flatten': {
+ desc: $localize`Deep flatten`,
+ requires: null,
+ allowEnable: false,
+ allowDisable: true
+ },
+ layering: {
+ desc: $localize`Layering`,
+ requires: null,
+ allowEnable: false,
+ allowDisable: false
+ },
+ 'exclusive-lock': {
+ desc: $localize`Exclusive lock`,
+ requires: null,
+ allowEnable: true,
+ allowDisable: true
+ },
+ 'object-map': {
+ desc: $localize`Object map (requires exclusive-lock)`,
+ requires: 'exclusive-lock',
+ allowEnable: true,
+ allowDisable: true,
+ initDisabled: true
+ },
+ 'fast-diff': {
+ desc: $localize`Fast diff (interlocked with object-map)`,
+ requires: 'object-map',
+ allowEnable: true,
+ allowDisable: true,
+ interlockedWith: 'object-map',
+ initDisabled: true
+ }
+ };
+ this.featuresList = this.objToArray(this.features);
+ this.createForm();
+ }
+
+ objToArray(obj: { [key: string]: any }) {
+ return _.map(obj, (o, key) => Object.assign(o, { key: key }));
+ }
+
+ createForm() {
+ this.rbdForm = new CdFormGroup(
+ {
+ parent: new FormControl(''),
+ name: new FormControl('', {
+ validators: [Validators.required, Validators.pattern(/^[^@/]+?$/)]
+ }),
+ pool: new FormControl(null, {
+ validators: [Validators.required]
+ }),
+ namespace: new FormControl(null),
+ useDataPool: new FormControl(false),
+ dataPool: new FormControl(null),
+ size: new FormControl(null, {
+ updateOn: 'blur'
+ }),
+ obj_size: new FormControl(this.defaultObjectSize),
+ features: new CdFormGroup(
+ this.featuresList.reduce((acc: object, e) => {
+ acc[e.key] = new FormControl({ value: false, disabled: !!e.initDisabled });
+ return acc;
+ }, {})
+ ),
+ mirroring: new FormControl(false),
+ schedule: new FormControl('', {
+ validators: [Validators.pattern(/^([0-9]+)d|([0-9]+)h|([0-9]+)m$/)] // check schedule interval to be in format - 1d or 1h or 1m
+ }),
+ mirroringMode: new FormControl(this.mirroringOptions[0]),
+ stripingUnit: new FormControl(this.defaultStripingUnit),
+ stripingCount: new FormControl(this.defaultStripingCount, {
+ updateOn: 'blur'
+ })
+ },
+ this.validateRbdForm(this.formatter)
+ );
+ }
+
+ disableForEdit() {
+ this.rbdForm.get('parent').disable();
+ this.rbdForm.get('pool').disable();
+ this.rbdForm.get('namespace').disable();
+ this.rbdForm.get('useDataPool').disable();
+ this.rbdForm.get('dataPool').disable();
+ this.rbdForm.get('obj_size').disable();
+ this.rbdForm.get('stripingUnit').disable();
+ this.rbdForm.get('stripingCount').disable();
+
+ /* RBD Image Format v1 */
+ this.rbdImage.subscribe((image: RbdModel) => {
+ if (image.image_format === RBDImageFormat.V1) {
+ this.rbdForm.get('deep-flatten').disable();
+ this.rbdForm.get('layering').disable();
+ this.rbdForm.get('exclusive-lock').disable();
+ }
+ });
+ }
+
+ disableForClone() {
+ this.rbdForm.get('parent').disable();
+ this.rbdForm.get('size').disable();
+ }
+
+ disableForCopy() {
+ this.rbdForm.get('parent').disable();
+ this.rbdForm.get('size').disable();
+ }
+
+ ngOnInit() {
+ this.prepareFormForAction();
+ this.gatherNeededData().subscribe(this.handleExternalData.bind(this));
+ }
+
+ setExclusiveLock() {
+ if (this.mirroring && this.rbdForm.get('mirroringMode').value === 'journal') {
+ this.rbdForm.get('exclusive-lock').setValue(true);
+ this.rbdForm.get('exclusive-lock').disable();
+ } else {
+ this.rbdForm.get('exclusive-lock').enable();
+ if (this.poolMirrorMode === 'pool') {
+ this.rbdForm.get('mirroringMode').setValue(this.mirroringOptions[0]);
+ }
+ }
+ }
+
+ setMirrorMode() {
+ this.mirroring = !this.mirroring;
+ this.setExclusiveLock();
+ }
+
+ setPoolMirrorMode() {
+ this.currentPoolName =
+ this.mode === this.rbdFormMode.editing
+ ? this.response?.pool_name
+ : this.rbdForm.getValue('pool');
+ if (this.currentPoolName) {
+ this.rbdMirroringService.refresh();
+ this.rbdMirroringService.subscribeSummary((data) => {
+ const pool = data.content_data.pools.find((o: any) => o.name === this.currentPoolName);
+ this.poolMirrorMode = pool.mirror_mode;
+
+ if (pool.mirror_mode === 'disabled') {
+ this.mirroring = false;
+ this.rbdForm.get('mirroring').setValue(this.mirroring);
+ this.rbdForm.get('mirroring').disable();
+ } else if (this.mode !== this.rbdFormMode.editing) {
+ this.rbdForm.get('mirroring').enable();
+ this.mirroring = true;
+ this.rbdForm.get('mirroring').setValue(this.mirroring);
+ }
+ });
+ }
+ this.setExclusiveLock();
+ }
+
+ private prepareFormForAction() {
+ const url = this.routerUrl;
+ if (url.startsWith('/block/rbd/edit')) {
+ this.mode = this.rbdFormMode.editing;
+ this.action = this.actionLabels.EDIT;
+ this.disableForEdit();
+ } else if (url.startsWith('/block/rbd/clone')) {
+ this.mode = this.rbdFormMode.cloning;
+ this.disableForClone();
+ this.action = this.actionLabels.CLONE;
+ } else if (url.startsWith('/block/rbd/copy')) {
+ this.mode = this.rbdFormMode.copying;
+ this.action = this.actionLabels.COPY;
+ this.disableForCopy();
+ } else {
+ this.action = this.actionLabels.CREATE;
+ }
+ _.each(this.features, (feature) => {
+ this.rbdForm
+ .get('features')
+ .get(feature.key)
+ .valueChanges.subscribe((value) => this.featureFormUpdate(feature.key, value));
+ });
+ }
+
+ private gatherNeededData(): Observable<object> {
+ const promises = {};
+ if (this.mode) {
+ // Mode is not set for creation
+ this.route.params.subscribe((params: { image_spec: string; snap: string }) => {
+ const imageSpec = ImageSpec.fromString(decodeURIComponent(params.image_spec));
+ if (params.snap) {
+ this.snapName = decodeURIComponent(params.snap);
+ }
+ promises['rbd'] = this.rbdService.get(imageSpec);
+ });
+ } else {
+ // New image
+ promises['defaultFeatures'] = this.rbdService.defaultFeatures();
+ }
+ if (this.mode !== this.rbdFormMode.editing && this.poolPermission.read) {
+ promises['pools'] = this.poolService.list([
+ 'pool_name',
+ 'type',
+ 'flags_names',
+ 'application_metadata'
+ ]);
+ }
+ return forkJoin(promises);
+ }
+
+ private handleExternalData(data: ExternalData) {
+ this.handlePoolData(data.pools);
+ this.setPoolMirrorMode();
+
+ if (data.defaultFeatures) {
+ // Fetched only during creation
+ this.setFeatures(data.defaultFeatures);
+ }
+
+ if (data.rbd) {
+ // Not fetched for creation
+ const resp = data.rbd;
+ this.setResponse(resp, this.snapName);
+ this.rbdImage.next(resp);
+ }
+
+ this.loadingReady();
+ }
+
+ private handlePoolData(data: Pool[]) {
+ if (!data) {
+ // Not fetched while editing
+ return;
+ }
+ const pools: Pool[] = [];
+ const dataPools = [];
+ for (const pool of data) {
+ if (this.rbdService.isRBDPool(pool)) {
+ if (pool.type === 'replicated') {
+ pools.push(pool);
+ dataPools.push(pool);
+ } else if (pool.type === 'erasure' && pool.flags_names.indexOf('ec_overwrites') !== -1) {
+ dataPools.push(pool);
+ }
+ }
+ }
+ this.pools = pools;
+ this.allPools = pools;
+ this.dataPools = dataPools;
+ this.allDataPools = dataPools;
+ if (this.pools.length === 1) {
+ const poolName = this.pools[0].pool_name;
+ this.rbdForm.get('pool').setValue(poolName);
+ this.onPoolChange(poolName);
+ }
+ if (this.allDataPools.length <= 1) {
+ this.rbdForm.get('useDataPool').disable();
+ }
+ }
+
+ onPoolChange(selectedPoolName: string) {
+ const dataPoolControl = this.rbdForm.get('dataPool');
+ if (dataPoolControl.value === selectedPoolName) {
+ dataPoolControl.setValue(null);
+ }
+ this.dataPools = this.allDataPools
+ ? this.allDataPools.filter((dataPool: any) => {
+ return dataPool.pool_name !== selectedPoolName;
+ })
+ : [];
+ this.namespaces = null;
+ if (selectedPoolName in this.namespacesByPoolCache) {
+ this.namespaces = this.namespacesByPoolCache[selectedPoolName];
+ } else {
+ this.rbdService.listNamespaces(selectedPoolName).subscribe((namespaces: any[]) => {
+ namespaces = namespaces.map((namespace) => namespace.namespace);
+ this.namespacesByPoolCache[selectedPoolName] = namespaces;
+ this.namespaces = namespaces;
+ });
+ }
+ this.rbdForm.get('namespace').setValue(null);
+ }
+
+ onUseDataPoolChange() {
+ if (!this.rbdForm.getValue('useDataPool')) {
+ this.rbdForm.get('dataPool').setValue(null);
+ this.onDataPoolChange(null);
+ }
+ }
+
+ onDataPoolChange(selectedDataPoolName: string) {
+ const newPools = this.allPools.filter((pool: Pool) => {
+ return pool.pool_name !== selectedDataPoolName;
+ });
+ if (this.rbdForm.getValue('pool') === selectedDataPoolName) {
+ this.rbdForm.get('pool').setValue(null);
+ }
+ this.pools = newPools;
+ }
+
+ validateRbdForm(formatter: FormatterService): ValidatorFn {
+ return (formGroup: CdFormGroup) => {
+ // Data Pool
+ const useDataPoolControl = formGroup.get('useDataPool');
+ const dataPoolControl = formGroup.get('dataPool');
+ let dataPoolControlErrors = null;
+ if (useDataPoolControl.value && dataPoolControl.value == null) {
+ dataPoolControlErrors = { required: true };
+ }
+ dataPoolControl.setErrors(dataPoolControlErrors);
+ // Size
+ const sizeControl = formGroup.get('size');
+ const objectSizeControl = formGroup.get('obj_size');
+ const objectSizeInBytes = formatter.toBytes(
+ objectSizeControl.value != null ? objectSizeControl.value : this.defaultObjectSize
+ );
+ const stripingCountControl = formGroup.get('stripingCount');
+ const stripingCount =
+ stripingCountControl.value != null ? stripingCountControl.value : this.defaultStripingCount;
+ let sizeControlErrors = null;
+ if (sizeControl.value === null) {
+ sizeControlErrors = { required: true };
+ } else {
+ const sizeInBytes = formatter.toBytes(sizeControl.value);
+ if (stripingCount * objectSizeInBytes > sizeInBytes) {
+ sizeControlErrors = { invalidSizeObject: true };
+ }
+ }
+ sizeControl.setErrors(sizeControlErrors);
+ // Striping Unit
+ const stripingUnitControl = formGroup.get('stripingUnit');
+ let stripingUnitControlErrors = null;
+ if (stripingUnitControl.value === null && stripingCountControl.value !== null) {
+ stripingUnitControlErrors = { required: true };
+ } else if (stripingUnitControl.value !== null) {
+ const stripingUnitInBytes = formatter.toBytes(stripingUnitControl.value);
+ if (stripingUnitInBytes > objectSizeInBytes) {
+ stripingUnitControlErrors = { invalidStripingUnit: true };
+ }
+ }
+ stripingUnitControl.setErrors(stripingUnitControlErrors);
+ // Striping Count
+ let stripingCountControlErrors = null;
+ if (stripingCountControl.value === null && stripingUnitControl.value !== null) {
+ stripingCountControlErrors = { required: true };
+ } else if (stripingCount < 1) {
+ stripingCountControlErrors = { min: true };
+ }
+ stripingCountControl.setErrors(stripingCountControlErrors);
+ return null;
+ };
+ }
+
+ deepBoxCheck(key: string, checked: boolean) {
+ const childFeatures = this.getDependentChildFeatures(key);
+ childFeatures.forEach((feature) => {
+ const featureControl = this.rbdForm.get(feature.key);
+ if (checked) {
+ featureControl.enable({ emitEvent: false });
+ } else {
+ featureControl.disable({ emitEvent: false });
+ featureControl.setValue(false, { emitEvent: false });
+ this.deepBoxCheck(feature.key, checked);
+ }
+
+ const featureFormGroup = this.rbdForm.get('features');
+ if (this.mode === this.rbdFormMode.editing && featureFormGroup.get(feature.key).enabled) {
+ if (this.response.features_name.indexOf(feature.key) !== -1 && !feature.allowDisable) {
+ featureFormGroup.get(feature.key).disable();
+ } else if (
+ this.response.features_name.indexOf(feature.key) === -1 &&
+ !feature.allowEnable
+ ) {
+ featureFormGroup.get(feature.key).disable();
+ }
+ }
+ });
+ }
+
+ protected getDependentChildFeatures(featureKey: string) {
+ return _.filter(this.features, (f) => f.requires === featureKey) || [];
+ }
+
+ interlockCheck(key: string, checked: boolean) {
+ // Adds a compatibility layer for Ceph cluster where the feature interlock of features hasn't
+ // been implemented yet. It disables the feature interlock for images which only have one of
+ // both interlocked features (at the time of this writing: object-map and fast-diff) enabled.
+ const feature = this.featuresList.find((f) => f.key === key);
+ if (this.response) {
+ // Ignore `create` page
+ const hasInterlockedFeature = feature.interlockedWith != null;
+ const dependentInterlockedFeature = this.featuresList.find(
+ (f) => f.interlockedWith === feature.key
+ );
+ const isOriginFeatureEnabled = !!this.response.features_name.find((e) => e === feature.key); // in this case: fast-diff
+ if (hasInterlockedFeature) {
+ const isLinkedEnabled = !!this.response.features_name.find(
+ (e) => e === feature.interlockedWith
+ ); // depends: object-map
+ if (isOriginFeatureEnabled !== isLinkedEnabled) {
+ return; // Ignore incompatible setting because it's from a previous cluster version
+ }
+ } else if (dependentInterlockedFeature) {
+ const isOtherInterlockedFeatureEnabled = !!this.response.features_name.find(
+ (e) => e === dependentInterlockedFeature.key
+ );
+ if (isOtherInterlockedFeatureEnabled !== isOriginFeatureEnabled) {
+ return; // Ignore incompatible setting because it's from a previous cluster version
+ }
+ }
+ }
+
+ if (checked) {
+ _.filter(this.features, (f) => f.interlockedWith === key).forEach((f) =>
+ this.rbdForm.get(f.key).setValue(true, { emitEvent: false })
+ );
+ } else {
+ if (feature.interlockedWith) {
+ // Don't skip emitting the event here, as it prevents `fast-diff` from
+ // becoming disabled when manually unchecked. This is because it
+ // triggers an update on `object-map` and if `object-map` doesn't emit,
+ // `fast-diff` will not be automatically disabled.
+ this.rbdForm.get('features').get(feature.interlockedWith).setValue(false);
+ }
+ }
+ }
+
+ featureFormUpdate(key: string, checked: boolean) {
+ if (checked) {
+ const required = this.features[key].requires;
+ if (required && !this.rbdForm.getValue(required)) {
+ this.rbdForm.get(`features.${key}`).setValue(false);
+ return;
+ }
+ }
+ this.deepBoxCheck(key, checked);
+ this.interlockCheck(key, checked);
+ }
+
+ setFeatures(features: Array<string>) {
+ const featuresControl = this.rbdForm.get('features');
+ _.forIn(this.features, (feature) => {
+ if (features.indexOf(feature.key) !== -1) {
+ featuresControl.get(feature.key).setValue(true);
+ }
+ this.featureFormUpdate(feature.key, featuresControl.get(feature.key).value);
+ });
+ }
+
+ setResponse(response: RbdFormResponseModel, snapName: string) {
+ this.response = response;
+ const imageSpec = new ImageSpec(
+ response.pool_name,
+ response.namespace,
+ response.name
+ ).toString();
+ if (this.mode === this.rbdFormMode.cloning) {
+ this.rbdForm.get('parent').setValue(`${imageSpec}@${snapName}`);
+ } else if (this.mode === this.rbdFormMode.copying) {
+ if (snapName) {
+ this.rbdForm.get('parent').setValue(`${imageSpec}@${snapName}`);
+ } else {
+ this.rbdForm.get('parent').setValue(`${imageSpec}`);
+ }
+ } else if (response.parent) {
+ const parent = response.parent;
+ this.rbdForm
+ .get('parent')
+ .setValue(`${parent.pool_name}/${parent.image_name}@${parent.snap_name}`);
+ }
+ if (this.mode === this.rbdFormMode.editing) {
+ this.rbdForm.get('name').setValue(response.name);
+ if (response?.mirror_mode === 'snapshot' || response.features_name.includes('journaling')) {
+ this.mirroring = true;
+ this.rbdForm.get('mirroring').setValue(this.mirroring);
+ this.rbdForm.get('mirroringMode').setValue(response?.mirror_mode);
+ this.rbdForm.get('schedule').setValue(response?.schedule_interval);
+ } else {
+ this.mirroring = false;
+ this.rbdForm.get('mirroring').setValue(this.mirroring);
+ }
+ this.setPoolMirrorMode();
+ }
+ this.rbdForm.get('pool').setValue(response.pool_name);
+ this.onPoolChange(response.pool_name);
+ this.rbdForm.get('namespace').setValue(response.namespace);
+ if (response.data_pool) {
+ this.rbdForm.get('useDataPool').setValue(true);
+ this.rbdForm.get('dataPool').setValue(response.data_pool);
+ }
+ this.rbdForm.get('size').setValue(this.dimlessBinaryPipe.transform(response.size));
+ this.rbdForm.get('obj_size').setValue(this.dimlessBinaryPipe.transform(response.obj_size));
+ this.setFeatures(response.features_name);
+ this.rbdForm
+ .get('stripingUnit')
+ .setValue(this.dimlessBinaryPipe.transform(response.stripe_unit));
+ this.rbdForm.get('stripingCount').setValue(response.stripe_count);
+ /* Configuration */
+ this.initializeConfigData.next({
+ initialData: this.response.configuration,
+ sourceType: RbdConfigurationSourceField.image
+ });
+ }
+
+ createRequest() {
+ const request = new RbdFormCreateRequestModel();
+ request.pool_name = this.rbdForm.getValue('pool');
+ request.namespace = this.rbdForm.getValue('namespace');
+ request.name = this.rbdForm.getValue('name');
+ request.schedule_interval = this.rbdForm.getValue('schedule');
+ request.size = this.formatter.toBytes(this.rbdForm.getValue('size'));
+ if (this.poolMirrorMode === 'image') {
+ request.mirror_mode = this.rbdForm.getValue('mirroringMode');
+ }
+ this.addObjectSizeAndStripingToRequest(request);
+ request.configuration = this.getDirtyConfigurationValues();
+ return request;
+ }
+
+ private addObjectSizeAndStripingToRequest(
+ request: RbdFormCreateRequestModel | RbdFormCloneRequestModel | RbdFormCopyRequestModel
+ ) {
+ request.obj_size = this.formatter.toBytes(this.rbdForm.getValue('obj_size'));
+ _.forIn(this.features, (feature) => {
+ if (this.rbdForm.getValue(feature.key)) {
+ request.features.push(feature.key);
+ }
+ });
+
+ if (this.mirroring && this.rbdForm.getValue('mirroringMode') === 'journal') {
+ request.features.push('journaling');
+ }
+
+ /* Striping */
+ request.stripe_unit = this.formatter.toBytes(this.rbdForm.getValue('stripingUnit'));
+ request.stripe_count = this.rbdForm.getValue('stripingCount');
+ request.data_pool = this.rbdForm.getValue('dataPool');
+ }
+
+ createAction(): Observable<any> {
+ const request = this.createRequest();
+ return this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/create', {
+ pool_name: request.pool_name,
+ namespace: request.namespace,
+ image_name: request.name,
+ schedule_interval: request.schedule_interval,
+ start_time: request.start_time
+ }),
+ call: this.rbdService.create(request)
+ });
+ }
+
+ editRequest() {
+ const request = new RbdFormEditRequestModel();
+ request.name = this.rbdForm.getValue('name');
+ request.schedule_interval = this.rbdForm.getValue('schedule');
+ request.name = this.rbdForm.getValue('name');
+ request.size = this.formatter.toBytes(this.rbdForm.getValue('size'));
+ _.forIn(this.features, (feature) => {
+ if (this.rbdForm.getValue(feature.key)) {
+ request.features.push(feature.key);
+ }
+ });
+ request.enable_mirror = this.rbdForm.getValue('mirroring');
+ if (request.enable_mirror) {
+ if (this.rbdForm.getValue('mirroringMode') === 'journal') {
+ request.features.push('journaling');
+ }
+ if (this.poolMirrorMode === 'image') {
+ request.mirror_mode = this.rbdForm.getValue('mirroringMode');
+ }
+ } else {
+ const index = request.features.indexOf('journaling', 0);
+ if (index > -1) {
+ request.features.splice(index, 1);
+ }
+ }
+ request.configuration = this.getDirtyConfigurationValues();
+ return request;
+ }
+
+ cloneRequest(): RbdFormCloneRequestModel {
+ const request = new RbdFormCloneRequestModel();
+ request.child_pool_name = this.rbdForm.getValue('pool');
+ request.child_namespace = this.rbdForm.getValue('namespace');
+ request.child_image_name = this.rbdForm.getValue('name');
+ this.addObjectSizeAndStripingToRequest(request);
+ request.configuration = this.getDirtyConfigurationValues(
+ true,
+ RbdConfigurationSourceField.image
+ );
+ return request;
+ }
+
+ editAction(): Observable<any> {
+ const imageSpec = new ImageSpec(
+ this.response.pool_name,
+ this.response.namespace,
+ this.response.name
+ );
+ return this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/edit', {
+ image_spec: imageSpec.toString()
+ }),
+ call: this.rbdService.update(imageSpec, this.editRequest())
+ });
+ }
+
+ cloneAction(): Observable<any> {
+ const request = this.cloneRequest();
+ const imageSpec = new ImageSpec(
+ this.response.pool_name,
+ this.response.namespace,
+ this.response.name
+ );
+ return this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/clone', {
+ parent_image_spec: imageSpec.toString(),
+ parent_snap_name: this.snapName,
+ child_pool_name: request.child_pool_name,
+ child_namespace: request.child_namespace,
+ child_image_name: request.child_image_name
+ }),
+ call: this.rbdService.cloneSnapshot(imageSpec, this.snapName, request)
+ });
+ }
+
+ copyRequest(): RbdFormCopyRequestModel {
+ const request = new RbdFormCopyRequestModel();
+ if (this.snapName) {
+ request.snapshot_name = this.snapName;
+ }
+ request.dest_pool_name = this.rbdForm.getValue('pool');
+ request.dest_namespace = this.rbdForm.getValue('namespace');
+ request.dest_image_name = this.rbdForm.getValue('name');
+ this.addObjectSizeAndStripingToRequest(request);
+ request.configuration = this.getDirtyConfigurationValues(
+ true,
+ RbdConfigurationSourceField.image
+ );
+ return request;
+ }
+
+ copyAction(): Observable<any> {
+ const request = this.copyRequest();
+ const imageSpec = new ImageSpec(
+ this.response.pool_name,
+ this.response.namespace,
+ this.response.name
+ );
+ return this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/copy', {
+ src_image_spec: imageSpec.toString(),
+ dest_pool_name: request.dest_pool_name,
+ dest_namespace: request.dest_namespace,
+ dest_image_name: request.dest_image_name
+ }),
+ call: this.rbdService.copy(imageSpec, request)
+ });
+ }
+
+ submit() {
+ if (!this.mode) {
+ this.rbdImage.next('create');
+ }
+ this.rbdImage
+ .pipe(
+ first(),
+ switchMap(() => {
+ if (this.mode === this.rbdFormMode.editing) {
+ return this.editAction();
+ } else if (this.mode === this.rbdFormMode.cloning) {
+ return this.cloneAction();
+ } else if (this.mode === this.rbdFormMode.copying) {
+ return this.copyAction();
+ } else {
+ return this.createAction();
+ }
+ })
+ )
+ .subscribe(
+ () => undefined,
+ () => this.rbdForm.setErrors({ cdSubmitButton: true }),
+ () => this.router.navigate(['/block/rbd'])
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts
new file mode 100644
index 000000000..262d79c95
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts
@@ -0,0 +1,26 @@
+import { RbdConfigurationEntry } from '~/app/shared/models/configuration';
+
+export class RbdFormModel {
+ name: string;
+ pool_name: string;
+ namespace: string;
+ data_pool: string;
+ size: number;
+
+ /* Striping */
+ obj_size: number;
+ stripe_unit: number;
+ stripe_count: number;
+
+ /* Configuration */
+ configuration: RbdConfigurationEntry[];
+
+ /* Deletion process */
+ source?: string;
+
+ enable_mirror?: boolean;
+ mirror_mode?: string;
+
+ schedule_interval: string;
+ start_time: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-parent.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-parent.model.ts
new file mode 100644
index 000000000..000717b0a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-parent.model.ts
@@ -0,0 +1,6 @@
+export class RbdParentModel {
+ image_name: string;
+ pool_name: string;
+ pool_namespace: string;
+ snap_name: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html
new file mode 100644
index 000000000..712d771c5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html
@@ -0,0 +1,128 @@
+<cd-rbd-tabs></cd-rbd-tabs>
+
+<cd-table #table
+ [data]="images"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="unique_id"
+ [searchableObjects]="true"
+ [serverSide]="true"
+ [count]="count"
+ forceIdentifier="true"
+ selectionType="single"
+ [hasDetails]="true"
+ [status]="tableStatus"
+ [maxLimit]="25"
+ [autoReload]="-1"
+ (fetchData)="taskListService.fetch($event)"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-rbd-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-rbd-details>
+</cd-table>
+
+<ng-template #scheduleStatus>
+ <div i18n
+ [innerHtml]="'Only available for RBD images with <strong>fast-diff</strong> enabled'"></div>
+</ng-template>
+
+<ng-template #provisionedNotAvailableTooltipTpl
+ let-row="row">
+ <span *ngIf="row.disk_usage === null && !row.features_name.includes('fast-diff'); else provisioned"
+ [ngbTooltip]="usageNotAvailableTooltipTpl"
+ placement="top"
+ i18n>N/A</span>
+ <ng-template #provisioned
+ i18n>{{row.disk_usage | dimlessBinary}}</ng-template>
+</ng-template>
+
+<ng-template #totalProvisionedNotAvailableTooltipTpl
+ let-row="row">
+ <span *ngIf="row.total_disk_usage === null && !row.features_name.includes('fast-diff'); else totalProvisioned"
+ [ngbTooltip]="usageNotAvailableTooltipTpl"
+ placement="top"
+ i18n>N/A</span>
+ <ng-template #totalProvisioned
+ i18n>{{row.total_disk_usage | dimlessBinary}}</ng-template>
+</ng-template>
+
+<ng-template #parentTpl
+ let-value="value">
+ <span *ngIf="value">{{ value.pool_name }}<span
+ *ngIf="value.pool_namespace">/{{ value.pool_namespace }}</span>/{{ value.image_name }}@{{ value.snap_name }}</span>
+ <span *ngIf="!value">-</span>
+</ng-template>
+
+<ng-template #mirroringTpl
+ let-value="value"
+ let-row="row">
+ <span *ngIf="value.length === 3; else probb"
+ class="badge badge-info">{{ value[0] }}</span>&nbsp;
+ <span *ngIf="value.length === 3"
+ class="badge badge-info"
+ [ngbTooltip]="'Next scheduled snapshot on' + ' ' + (value[2] | cdDate)">{{ value[1] }}</span>
+ <span *ngIf="row.primary === true"
+ class="badge badge-info"
+ i18n>primary</span>
+ <span *ngIf="row.primary === false"
+ class="badge badge-info"
+ i18n>secondary</span>
+ <ng-template #probb>
+ <span class="badge badge-info">{{ value }}</span>
+ </ng-template>
+</ng-template>
+
+<ng-template #flattenTpl
+ let-value>
+ You are about to flatten
+ <strong>{{ value.child }}</strong>.
+ <br>
+ <br> All blocks will be copied from parent
+ <strong>{{ value.parent }}</strong> to child
+ <strong>{{ value.child }}</strong>.
+</ng-template>
+
+<ng-template #deleteTpl
+ let-hasSnapshots="hasSnapshots"
+ let-snapshots="snapshots">
+ <div class="alert alert-warning"
+ *ngIf="hasSnapshots"
+ role="alert">
+ <span i18n>Deleting this image will also delete all its snapshots.</span>
+ <br>
+ <ng-container *ngIf="snapshots.length > 0">
+ <span i18n>The following snapshots are currently protected and will be removed:</span>
+ <ul>
+ <li *ngFor="let snapshot of snapshots">{{ snapshot }}</li>
+ </ul>
+ </ng-container>
+ </div>
+</ng-template>
+
+<ng-template #removingStatTpl
+ let-column="column"
+ let-value="value"
+ let-row="row">
+
+ <i [ngClass]="[icons.spinner, icons.spin]"
+ *ngIf="row.cdExecuting"></i>
+ <span [ngClass]="column?.customTemplateConfig?.valueClass">
+ {{ value }}
+ </span>
+ <span *ngIf="row.cdExecuting"
+ [ngClass]="column?.customTemplateConfig?.executingClass ?
+ column.customTemplateConfig.executingClass :
+ 'text-muted italic'">
+ ({{ row.cdExecuting }})
+ </span>
+ <i *ngIf="row.source && row.source === 'REMOVING'"
+ i18n-title
+ title="RBD in status 'Removing'"
+ class="{{ icons.warning }} warn"></i>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss
new file mode 100644
index 000000000..4cfa4e8da
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss
@@ -0,0 +1,5 @@
+@use './src/styles/vendor/variables' as vv;
+
+.warn {
+ color: vv.$warning;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
new file mode 100644
index 000000000..fa7a772f0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
@@ -0,0 +1,438 @@
+import { HttpHeaders } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, expectItemTasks, PermissionHelper } from '~/testing/unit-test-helper';
+import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component';
+import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
+import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdTabsComponent } from '../rbd-tabs/rbd-tabs.component';
+import { RbdListComponent } from './rbd-list.component';
+import { RbdModel } from './rbd-model';
+
+describe('RbdListComponent', () => {
+ let fixture: ComponentFixture<RbdListComponent>;
+ let component: RbdListComponent;
+ let summaryService: SummaryService;
+ let rbdService: RbdService;
+ let headers: HttpHeaders;
+
+ const refresh = (data: any) => {
+ summaryService['summaryDataSource'].next(data);
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ NgbNavModule,
+ NgbTooltipModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule,
+ HttpClientTestingModule
+ ],
+ declarations: [
+ RbdListComponent,
+ RbdDetailsComponent,
+ RbdSnapshotListComponent,
+ RbdConfigurationListComponent,
+ RbdTabsComponent
+ ],
+ providers: [TaskListService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdListComponent);
+ component = fixture.componentInstance;
+ summaryService = TestBed.inject(SummaryService);
+ rbdService = TestBed.inject(RbdService);
+ headers = new HttpHeaders().set('X-Total-Count', '10');
+
+ // this is needed because summaryService isn't being reset after each test.
+ summaryService['summaryDataSource'] = new BehaviorSubject(null);
+ summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('after ngOnInit', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ spyOn(rbdService, 'list').and.callThrough();
+ });
+
+ it('should load images on init', () => {
+ refresh({});
+ expect(rbdService.list).toHaveBeenCalled();
+ });
+
+ it('should not load images on init because no data', () => {
+ refresh(undefined);
+ expect(rbdService.list).not.toHaveBeenCalled();
+ });
+
+ it('should call error function on init when summary service fails', () => {
+ spyOn(component.table, 'reset');
+ summaryService['summaryDataSource'].error(undefined);
+ expect(component.table.reset).toHaveBeenCalled();
+ });
+ });
+
+ describe('handling of provisioned columns', () => {
+ let rbdServiceListSpy: jasmine.Spy;
+
+ const images = [
+ {
+ name: 'img1',
+ pool_name: 'rbd',
+ features_name: ['layering', 'exclusive-lock'],
+ disk_usage: null,
+ total_disk_usage: null
+ },
+ {
+ name: 'img2',
+ pool_name: 'rbd',
+ features_name: ['layering', 'exclusive-lock', 'object-map', 'fast-diff'],
+ disk_usage: 1024,
+ total_disk_usage: 1024
+ }
+ ];
+
+ beforeEach(() => {
+ component.images = images;
+ refresh({ executing_tasks: [], finished_tasks: [] });
+ rbdServiceListSpy = spyOn(rbdService, 'list');
+ });
+
+ it('should display N/A for Provisioned & Total Provisioned columns if disk usage is null', () => {
+ rbdServiceListSpy.and.callFake(() =>
+ of([{ pool_name: 'rbd', value: images, headers: headers }])
+ );
+ fixture.detectChanges();
+ const spanWithoutFastDiff = fixture.debugElement.nativeElement.querySelectorAll(
+ '.datatable-body-cell-label span'
+ );
+ // check image with disk usage = null & fast-diff disabled
+ expect(spanWithoutFastDiff[6].textContent).toBe('N/A');
+
+ images[0]['features_name'] = ['layering', 'exclusive-lock', 'object-map', 'fast-diff'];
+ component.images = images;
+ refresh({ executing_tasks: [], finished_tasks: [] });
+
+ rbdServiceListSpy.and.callFake(() =>
+ of([{ pool_name: 'rbd', value: images, headers: headers }])
+ );
+ fixture.detectChanges();
+
+ const spanWithFastDiff = fixture.debugElement.nativeElement.querySelectorAll(
+ '.datatable-body-cell-label span'
+ );
+ // check image with disk usage = null & fast-diff changed to enabled
+ expect(spanWithFastDiff[6].textContent).toBe('-');
+ });
+ });
+
+ describe('handling of deletion', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('should check if there are no snapshots', () => {
+ component.selection.add({
+ id: '-1',
+ name: 'rbd1',
+ pool_name: 'rbd'
+ });
+ expect(component.hasSnapshots()).toBeFalsy();
+ });
+
+ it('should check if there are snapshots', () => {
+ component.selection.add({
+ id: '-1',
+ name: 'rbd1',
+ pool_name: 'rbd',
+ snapshots: [{}, {}]
+ });
+ expect(component.hasSnapshots()).toBeTruthy();
+ });
+
+ it('should get delete disable description', () => {
+ component.selection.add({
+ id: '-1',
+ name: 'rbd1',
+ pool_name: 'rbd',
+ snapshots: [
+ {
+ children: [{}]
+ }
+ ]
+ });
+ expect(component.getDeleteDisableDesc(component.selection)).toBe(
+ 'This RBD has cloned snapshots. Please delete related RBDs before deleting this RBD.'
+ );
+ });
+
+ it('should list all protected snapshots', () => {
+ component.selection.add({
+ id: '-1',
+ name: 'rbd1',
+ pool_name: 'rbd',
+ snapshots: [
+ {
+ name: 'snap1',
+ is_protected: false
+ },
+ {
+ name: 'snap2',
+ is_protected: true
+ }
+ ]
+ });
+
+ expect(component.listProtectedSnapshots()).toEqual(['snap2']);
+ });
+ });
+
+ describe('handling of executing tasks', () => {
+ let images: RbdModel[];
+
+ const addImage = (name: string) => {
+ const model = new RbdModel();
+ model.id = '-1';
+ model.name = name;
+ model.pool_name = 'rbd';
+ images.push(model);
+ };
+
+ const addTask = (name: string, image_name: string) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ switch (task.name) {
+ case 'rbd/copy':
+ task.metadata = {
+ dest_pool_name: 'rbd',
+ dest_namespace: null,
+ dest_image_name: 'd'
+ };
+ break;
+ case 'rbd/clone':
+ task.metadata = {
+ child_pool_name: 'rbd',
+ child_namespace: null,
+ child_image_name: 'd'
+ };
+ break;
+ case 'rbd/create':
+ task.metadata = {
+ pool_name: 'rbd',
+ namespace: null,
+ image_name: image_name
+ };
+ break;
+ default:
+ task.metadata = {
+ image_spec: `rbd/${image_name}`
+ };
+ break;
+ }
+ summaryService.addRunningTask(task);
+ };
+
+ beforeEach(() => {
+ images = [];
+ addImage('a');
+ addImage('b');
+ addImage('c');
+ component.images = images;
+ refresh({ executing_tasks: [], finished_tasks: [] });
+ spyOn(rbdService, 'list').and.callFake(() =>
+ of([{ pool_name: 'rbd', value: images, headers: headers }])
+ );
+ fixture.detectChanges();
+ });
+
+ it('should gets all images without tasks', () => {
+ expect(component.images.length).toBe(3);
+ expect(component.images.every((image: any) => !image.cdExecuting)).toBeTruthy();
+ });
+
+ it('should add a new image from a task', () => {
+ addTask('rbd/create', 'd');
+ expect(component.images.length).toBe(4);
+ expectItemTasks(component.images[0], undefined);
+ expectItemTasks(component.images[1], undefined);
+ expectItemTasks(component.images[2], undefined);
+ expectItemTasks(component.images[3], 'Creating');
+ });
+
+ it('should show when a image is being cloned', () => {
+ addTask('rbd/clone', 'd');
+ expect(component.images.length).toBe(4);
+ expectItemTasks(component.images[0], undefined);
+ expectItemTasks(component.images[1], undefined);
+ expectItemTasks(component.images[2], undefined);
+ expectItemTasks(component.images[3], 'Cloning');
+ });
+
+ it('should show when a image is being copied', () => {
+ addTask('rbd/copy', 'd');
+ expect(component.images.length).toBe(4);
+ expectItemTasks(component.images[0], undefined);
+ expectItemTasks(component.images[1], undefined);
+ expectItemTasks(component.images[2], undefined);
+ expectItemTasks(component.images[3], 'Copying');
+ });
+
+ it('should show when an existing image is being modified', () => {
+ addTask('rbd/edit', 'a');
+ expectItemTasks(component.images[0], 'Updating');
+ addTask('rbd/delete', 'b');
+ expectItemTasks(component.images[1], 'Deleting');
+ addTask('rbd/flatten', 'c');
+ expectItemTasks(component.images[2], 'Flattening');
+ expect(component.images.length).toBe(3);
+ });
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: [
+ 'Create',
+ 'Edit',
+ 'Copy',
+ 'Flatten',
+ 'Resync',
+ 'Delete',
+ 'Move to Trash',
+ 'Remove Scheduling',
+ 'Promote',
+ 'Demote'
+ ],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: [
+ 'Create',
+ 'Edit',
+ 'Copy',
+ 'Flatten',
+ 'Resync',
+ 'Remove Scheduling',
+ 'Promote',
+ 'Demote'
+ ],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Copy', 'Delete', 'Move to Trash'],
+ primary: { multiple: 'Create', executing: 'Copy', single: 'Copy', no: 'Create' }
+ },
+ create: {
+ actions: ['Create', 'Copy'],
+ primary: { multiple: 'Create', executing: 'Copy', single: 'Copy', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: [
+ 'Edit',
+ 'Flatten',
+ 'Resync',
+ 'Delete',
+ 'Move to Trash',
+ 'Remove Scheduling',
+ 'Promote',
+ 'Demote'
+ ],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit', 'Flatten', 'Resync', 'Remove Scheduling', 'Promote', 'Demote'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete', 'Move to Trash'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ const getActionDisable = (name: string) =>
+ component.tableActions.find((o) => o.name === name).disable;
+
+ const testActions = (selection: any, expected: { [action: string]: string | boolean }) => {
+ expect(getActionDisable('Edit')(selection)).toBe(expected.edit || false);
+ expect(getActionDisable('Delete')(selection)).toBe(expected.delete || false);
+ expect(getActionDisable('Copy')(selection)).toBe(expected.copy || false);
+ expect(getActionDisable('Flatten')(selection)).toBeTruthy();
+ expect(getActionDisable('Move to Trash')(selection)).toBe(expected.moveTrash || false);
+ };
+
+ it('should test TableActions with valid/invalid image name', () => {
+ component.selection.selected = [
+ {
+ name: 'foobar',
+ pool_name: 'rbd',
+ snapshots: []
+ }
+ ];
+ testActions(component.selection, {});
+
+ component.selection.selected = [
+ {
+ name: 'foo/bar',
+ pool_name: 'rbd',
+ snapshots: []
+ }
+ ];
+ const message = `This RBD image has an invalid name and can't be managed by ceph.`;
+ const expected = {
+ edit: message,
+ delete: message,
+ copy: message,
+ moveTrash: message
+ };
+ testActions(component.selection, expected);
+ });
+
+ it('should disable edit, copy, flatten and move action if RBD is in status `Removing`', () => {
+ component.selection.selected = [
+ {
+ name: 'foobar',
+ pool_name: 'rbd',
+ snapshots: [],
+ source: 'REMOVING'
+ }
+ ];
+
+ const message = `Action not possible for an RBD in status 'Removing'`;
+ const expected = {
+ edit: message,
+ copy: message,
+ moveTrash: message
+ };
+ testActions(component.selection, expected);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
new file mode 100644
index 000000000..a24e59f82
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
@@ -0,0 +1,629 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Observable, Subscriber } from 'rxjs';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { TableStatus } from '~/app/shared/classes/table-status';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { Permission } from '~/app/shared/models/permissions';
+import { Task } from '~/app/shared/models/task';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { CdTableServerSideService } from '~/app/shared/services/cd-table-server-side.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { RbdFormEditRequestModel } from '../rbd-form/rbd-form-edit-request.model';
+import { RbdParentModel } from '../rbd-form/rbd-parent.model';
+import { RbdTrashMoveModalComponent } from '../rbd-trash-move-modal/rbd-trash-move-modal.component';
+import { RBDImageFormat, RbdModel } from './rbd-model';
+
+const BASE_URL = 'block/rbd';
+
+@Component({
+ selector: 'cd-rbd-list',
+ templateUrl: './rbd-list.component.html',
+ styleUrls: ['./rbd-list.component.scss'],
+ providers: [
+ TaskListService,
+ { provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }
+ ]
+})
+export class RbdListComponent extends ListWithDetails implements OnInit {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+ @ViewChild('usageTpl')
+ usageTpl: TemplateRef<any>;
+ @ViewChild('parentTpl', { static: true })
+ parentTpl: TemplateRef<any>;
+ @ViewChild('nameTpl')
+ nameTpl: TemplateRef<any>;
+ @ViewChild('mirroringTpl', { static: true })
+ mirroringTpl: TemplateRef<any>;
+ @ViewChild('flattenTpl', { static: true })
+ flattenTpl: TemplateRef<any>;
+ @ViewChild('deleteTpl', { static: true })
+ deleteTpl: TemplateRef<any>;
+ @ViewChild('removingStatTpl', { static: true })
+ removingStatTpl: TemplateRef<any>;
+ @ViewChild('provisionedNotAvailableTooltipTpl', { static: true })
+ provisionedNotAvailableTooltipTpl: TemplateRef<any>;
+ @ViewChild('totalProvisionedNotAvailableTooltipTpl', { static: true })
+ totalProvisionedNotAvailableTooltipTpl: TemplateRef<any>;
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ images: any;
+ columns: CdTableColumn[];
+ retries: number;
+ tableStatus = new TableStatus('light');
+ selection = new CdTableSelection();
+ icons = Icons;
+ count = 0;
+ private tableContext: CdTableFetchDataContext = null;
+ modalRef: NgbModalRef;
+
+ builders = {
+ 'rbd/create': (metadata: object) =>
+ this.createRbdFromTask(metadata['pool_name'], metadata['namespace'], metadata['image_name']),
+ 'rbd/delete': (metadata: object) => this.createRbdFromTaskImageSpec(metadata['image_spec']),
+ 'rbd/clone': (metadata: object) =>
+ this.createRbdFromTask(
+ metadata['child_pool_name'],
+ metadata['child_namespace'],
+ metadata['child_image_name']
+ ),
+ 'rbd/copy': (metadata: object) =>
+ this.createRbdFromTask(
+ metadata['dest_pool_name'],
+ metadata['dest_namespace'],
+ metadata['dest_image_name']
+ )
+ };
+ remove_scheduling: boolean;
+
+ private createRbdFromTaskImageSpec(imageSpecStr: string): RbdModel {
+ const imageSpec = ImageSpec.fromString(imageSpecStr);
+ return this.createRbdFromTask(imageSpec.poolName, imageSpec.namespace, imageSpec.imageName);
+ }
+
+ private createRbdFromTask(pool: string, namespace: string, name: string): RbdModel {
+ const model = new RbdModel();
+ model.id = '-1';
+ model.unique_id = '-1';
+ model.name = name;
+ model.namespace = namespace;
+ model.pool_name = pool;
+ model.image_format = RBDImageFormat.V2;
+ return model;
+ }
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdService: RbdService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private dimlessPipe: DimlessPipe,
+ private modalService: ModalService,
+ private taskWrapper: TaskWrapperService,
+ public taskListService: TaskListService,
+ private urlBuilder: URLBuilderService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().rbdImage;
+ const getImageUri = () =>
+ this.selection.first() &&
+ new ImageSpec(
+ this.selection.first().pool_name,
+ this.selection.first().namespace,
+ this.selection.first().name
+ ).toStringEncoded();
+ const addAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection,
+ name: this.actionLabels.CREATE
+ };
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () => this.urlBuilder.getEdit(getImageUri()),
+ name: this.actionLabels.EDIT,
+ disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) || this.getInvalidNameDisable(selection)
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteRbdModal(),
+ name: this.actionLabels.DELETE,
+ disable: (selection: CdTableSelection) => this.getDeleteDisableDesc(selection)
+ };
+ const resyncAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.refresh,
+ click: () => this.resyncRbdModal(),
+ name: this.actionLabels.RESYNC,
+ disable: (selection: CdTableSelection) => this.getResyncDisableDesc(selection)
+ };
+ const copyAction: CdTableAction = {
+ permission: 'create',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
+ disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) ||
+ this.getInvalidNameDisable(selection) ||
+ !!selection.first().cdExecuting,
+ icon: Icons.copy,
+ routerLink: () => `/block/rbd/copy/${getImageUri()}`,
+ name: this.actionLabels.COPY
+ };
+ const flattenAction: CdTableAction = {
+ permission: 'update',
+ disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) ||
+ this.getInvalidNameDisable(selection) ||
+ selection.first().cdExecuting ||
+ !selection.first().parent,
+ icon: Icons.flatten,
+ click: () => this.flattenRbdModal(),
+ name: this.actionLabels.FLATTEN
+ };
+ const moveAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.trash,
+ click: () => this.trashRbdModal(),
+ name: this.actionLabels.TRASH,
+ disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) ||
+ this.getInvalidNameDisable(selection) ||
+ selection.first().image_format === RBDImageFormat.V1
+ };
+ const removeSchedulingAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.removeSchedulingModal(),
+ name: this.actionLabels.REMOVE_SCHEDULING,
+ disable: (selection: CdTableSelection) =>
+ this.getRemovingStatusDesc(selection) ||
+ this.getInvalidNameDisable(selection) ||
+ selection.first().schedule_info === undefined
+ };
+ const promoteAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.actionPrimary(true),
+ name: this.actionLabels.PROMOTE,
+ visible: () => this.selection.first() != null && !this.selection.first().primary
+ };
+ const demoteAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.actionPrimary(false),
+ name: this.actionLabels.DEMOTE,
+ visible: () => this.selection.first() != null && this.selection.first().primary
+ };
+ this.tableActions = [
+ addAction,
+ editAction,
+ copyAction,
+ flattenAction,
+ resyncAction,
+ deleteAction,
+ moveAction,
+ removeSchedulingAction,
+ promoteAction,
+ demoteAction
+ ];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 2,
+ cellTemplate: this.removingStatTpl
+ },
+ {
+ name: $localize`Pool`,
+ prop: 'pool_name',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Namespace`,
+ prop: 'namespace',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Size`,
+ prop: 'size',
+ flexGrow: 1,
+ cellClass: 'text-right',
+ sortable: false,
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`Objects`,
+ prop: 'num_objs',
+ flexGrow: 1,
+ cellClass: 'text-right',
+ sortable: false,
+ pipe: this.dimlessPipe
+ },
+ {
+ name: $localize`Object size`,
+ prop: 'obj_size',
+ flexGrow: 1,
+ cellClass: 'text-right',
+ sortable: false,
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`Provisioned`,
+ prop: 'disk_usage',
+ cellClass: 'text-center',
+ flexGrow: 1,
+ pipe: this.dimlessBinaryPipe,
+ sortable: false,
+ cellTemplate: this.provisionedNotAvailableTooltipTpl
+ },
+ {
+ name: $localize`Total provisioned`,
+ prop: 'total_disk_usage',
+ cellClass: 'text-center',
+ flexGrow: 1,
+ pipe: this.dimlessBinaryPipe,
+ sortable: false,
+ cellTemplate: this.totalProvisionedNotAvailableTooltipTpl
+ },
+ {
+ name: $localize`Parent`,
+ prop: 'parent',
+ flexGrow: 2,
+ sortable: false,
+ cellTemplate: this.parentTpl
+ },
+ {
+ name: $localize`Mirroring`,
+ prop: 'mirror_mode',
+ flexGrow: 3,
+ sortable: false,
+ cellTemplate: this.mirroringTpl
+ }
+ ];
+
+ const itemFilter = (entry: Record<string, any>, task: Task) => {
+ let taskImageSpec: string;
+ switch (task.name) {
+ case 'rbd/copy':
+ taskImageSpec = new ImageSpec(
+ task.metadata['dest_pool_name'],
+ task.metadata['dest_namespace'],
+ task.metadata['dest_image_name']
+ ).toString();
+ break;
+ case 'rbd/clone':
+ taskImageSpec = new ImageSpec(
+ task.metadata['child_pool_name'],
+ task.metadata['child_namespace'],
+ task.metadata['child_image_name']
+ ).toString();
+ break;
+ case 'rbd/create':
+ taskImageSpec = new ImageSpec(
+ task.metadata['pool_name'],
+ task.metadata['namespace'],
+ task.metadata['image_name']
+ ).toString();
+ break;
+ default:
+ taskImageSpec = task.metadata['image_spec'];
+ break;
+ }
+ return (
+ taskImageSpec === new ImageSpec(entry.pool_name, entry.namespace, entry.name).toString()
+ );
+ };
+
+ const taskFilter = (task: Task) => {
+ return [
+ 'rbd/clone',
+ 'rbd/copy',
+ 'rbd/create',
+ 'rbd/delete',
+ 'rbd/edit',
+ 'rbd/flatten',
+ 'rbd/trash/move'
+ ].includes(task.name);
+ };
+
+ this.taskListService.init(
+ (context) => this.getRbdImages(context),
+ (resp) => this.prepareResponse(resp),
+ (images) => (this.images = images),
+ () => this.onFetchError(),
+ taskFilter,
+ itemFilter,
+ this.builders
+ );
+ }
+
+ onFetchError() {
+ this.table.reset(); // Disable loading indicator.
+ this.tableStatus = new TableStatus('danger');
+ }
+
+ getRbdImages(context: CdTableFetchDataContext) {
+ if (context !== null) {
+ this.tableContext = context;
+ }
+ if (this.tableContext == null) {
+ this.tableContext = new CdTableFetchDataContext(() => undefined);
+ }
+ return this.rbdService.list(this.tableContext?.toParams());
+ }
+
+ prepareResponse(resp: any[]): any[] {
+ let images: any[] = [];
+
+ resp.forEach((pool) => {
+ images = images.concat(pool.value);
+ });
+
+ images.forEach((image) => {
+ if (image.schedule_info !== undefined) {
+ let scheduling: any[] = [];
+ const scheduleStatus = 'scheduled';
+ let nextSnapshotDate = +new Date(image.schedule_info.schedule_time);
+ const offset = new Date().getTimezoneOffset();
+ nextSnapshotDate = nextSnapshotDate + Math.abs(offset) * 60000;
+ scheduling.push(image.mirror_mode, scheduleStatus, nextSnapshotDate);
+ image.mirror_mode = scheduling;
+ scheduling = [];
+ }
+ });
+
+ if (images.length > 0) {
+ this.count = CdTableServerSideService.getCount(resp[0]);
+ } else {
+ this.count = 0;
+ }
+ return images;
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteRbdModal() {
+ const poolName = this.selection.first().pool_name;
+ const namespace = this.selection.first().namespace;
+ const imageName = this.selection.first().name;
+ const imageSpec = new ImageSpec(poolName, namespace, imageName);
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'RBD',
+ itemNames: [imageSpec],
+ bodyTemplate: this.deleteTpl,
+ bodyContext: {
+ hasSnapshots: this.hasSnapshots(),
+ snapshots: this.listProtectedSnapshots()
+ },
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/delete', {
+ image_spec: imageSpec.toString()
+ }),
+ call: this.rbdService.delete(imageSpec)
+ })
+ });
+ }
+
+ resyncRbdModal() {
+ const poolName = this.selection.first().pool_name;
+ const namespace = this.selection.first().namespace;
+ const imageName = this.selection.first().name;
+ const imageSpec = new ImageSpec(poolName, namespace, imageName);
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'RBD',
+ itemNames: [imageSpec],
+ actionDescription: 'resync',
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/edit', {
+ image_spec: imageSpec.toString()
+ }),
+ call: this.rbdService.update(imageSpec, { resync: true })
+ })
+ });
+ }
+
+ trashRbdModal() {
+ const initialState = {
+ poolName: this.selection.first().pool_name,
+ namespace: this.selection.first().namespace,
+ imageName: this.selection.first().name,
+ hasSnapshots: this.hasSnapshots()
+ };
+ this.modalRef = this.modalService.show(RbdTrashMoveModalComponent, initialState);
+ }
+
+ flattenRbd(imageSpec: ImageSpec) {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/flatten', {
+ image_spec: imageSpec.toString()
+ }),
+ call: this.rbdService.flatten(imageSpec)
+ })
+ .subscribe({
+ complete: () => {
+ this.modalRef.close();
+ }
+ });
+ }
+
+ flattenRbdModal() {
+ const poolName = this.selection.first().pool_name;
+ const namespace = this.selection.first().namespace;
+ const imageName = this.selection.first().name;
+ const parent: RbdParentModel = this.selection.first().parent;
+ const parentImageSpec = new ImageSpec(
+ parent.pool_name,
+ parent.pool_namespace,
+ parent.image_name
+ );
+ const childImageSpec = new ImageSpec(poolName, namespace, imageName);
+
+ const initialState = {
+ titleText: 'RBD flatten',
+ buttonText: 'Flatten',
+ bodyTpl: this.flattenTpl,
+ bodyData: {
+ parent: `${parentImageSpec}@${parent.snap_name}`,
+ child: childImageSpec.toString()
+ },
+ onSubmit: () => {
+ this.flattenRbd(childImageSpec);
+ }
+ };
+
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, initialState);
+ }
+
+ editRequest() {
+ const request = new RbdFormEditRequestModel();
+ request.remove_scheduling = !request.remove_scheduling;
+ return request;
+ }
+
+ removeSchedulingModal() {
+ const imageName = this.selection.first().name;
+
+ const imageSpec = new ImageSpec(
+ this.selection.first().pool_name,
+ this.selection.first().namespace,
+ this.selection.first().name
+ );
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ actionDescription: 'remove scheduling on',
+ itemDescription: $localize`image`,
+ itemNames: [`${imageName}`],
+ submitActionObservable: () =>
+ new Observable((observer: Subscriber<any>) => {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/edit', {
+ image_spec: imageSpec.toString()
+ }),
+ call: this.rbdService.update(imageSpec, this.editRequest())
+ })
+ .subscribe({
+ error: (resp) => observer.error(resp),
+ complete: () => {
+ this.modalRef.close();
+ }
+ });
+ })
+ });
+ }
+
+ actionPrimary(primary: boolean) {
+ const request = new RbdFormEditRequestModel();
+ request.primary = primary;
+ request.features = null;
+ const imageSpec = new ImageSpec(
+ this.selection.first().pool_name,
+ this.selection.first().namespace,
+ this.selection.first().name
+ );
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/edit', {
+ image_spec: imageSpec.toString()
+ }),
+ call: this.rbdService.update(imageSpec, request)
+ })
+ .subscribe();
+ }
+
+ hasSnapshots() {
+ const snapshots = this.selection.first()['snapshots'] || [];
+ return snapshots.length > 0;
+ }
+
+ hasClonedSnapshots(image: object) {
+ const snapshots = image['snapshots'] || [];
+ return snapshots.some((snap: object) => snap['children'] && snap['children'].length > 0);
+ }
+
+ listProtectedSnapshots() {
+ const first = this.selection.first();
+ const snapshots = first['snapshots'];
+ return snapshots.reduce((accumulator: string[], snap: object) => {
+ if (snap['is_protected']) {
+ accumulator.push(snap['name']);
+ }
+ return accumulator;
+ }, []);
+ }
+
+ getDeleteDisableDesc(selection: CdTableSelection): string | boolean {
+ const first = selection.first();
+
+ if (first && this.hasClonedSnapshots(first)) {
+ return $localize`This RBD has cloned snapshots. Please delete related RBDs before deleting this RBD.`;
+ }
+
+ return this.getInvalidNameDisable(selection) || this.hasClonedSnapshots(selection.first());
+ }
+
+ getResyncDisableDesc(selection: CdTableSelection): string | boolean {
+ const first = selection.first();
+
+ if (first && this.imageIsPrimary(first)) {
+ return $localize`Primary RBD images cannot be resynced`;
+ }
+
+ return this.getInvalidNameDisable(selection);
+ }
+
+ imageIsPrimary(image: object) {
+ return image['primary'];
+ }
+ getInvalidNameDisable(selection: CdTableSelection): string | boolean {
+ const first = selection.first();
+
+ if (first?.name?.match(/[@/]/)) {
+ return $localize`This RBD image has an invalid name and can't be managed by ceph.`;
+ }
+
+ return !selection.first() || !selection.hasSingleSelection;
+ }
+
+ getRemovingStatusDesc(selection: CdTableSelection): string | boolean {
+ const first = selection.first();
+ if (first?.source === 'REMOVING') {
+ return $localize`Action not possible for an RBD in status 'Removing'`;
+ }
+ return false;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts
new file mode 100644
index 000000000..0a265dea8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts
@@ -0,0 +1,15 @@
+export class RbdModel {
+ id: string;
+ unique_id: string;
+ name: string;
+ pool_name: string;
+ namespace: string;
+ image_format: RBDImageFormat;
+
+ cdExecuting: string;
+}
+
+export enum RBDImageFormat {
+ V1 = 1,
+ V2 = 2
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.html
new file mode 100644
index 000000000..9d76cba09
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.html
@@ -0,0 +1,79 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>Create Namespace</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="namespaceForm"
+ #formDir="ngForm"
+ [formGroup]="namespaceForm"
+ novalidate>
+ <div class="modal-body">
+
+ <!-- Pool -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="pool"
+ i18n>Pool</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Pool name..."
+ id="pool"
+ name="pool"
+ formControlName="pool"
+ *ngIf="!poolPermission.read">
+ <select id="pool"
+ name="pool"
+ class="form-control"
+ formControlName="pool"
+ *ngIf="poolPermission.read">
+ <option *ngIf="pools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="pools !== null && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No rbd pools available --</option>
+ <option *ngIf="pools !== null && pools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a pool --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </select>
+ <span *ngIf="namespaceForm.showError('pool', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="namespace"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Namespace name..."
+ id="namespace"
+ name="namespace"
+ formControlName="namespace"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="namespaceForm.showError('namespace', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="namespaceForm.showError('namespace', formDir, 'namespaceExists')"
+ i18n>Namespace already exists.</span>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="namespaceForm"
+ [submitText]="actionLabels.CREATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.spec.ts
new file mode 100644
index 000000000..8300fc655
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.spec.ts
@@ -0,0 +1,39 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdNamespaceFormModalComponent } from './rbd-namespace-form-modal.component';
+
+describe('RbdNamespaceFormModalComponent', () => {
+ let component: RbdNamespaceFormModalComponent;
+ let fixture: ComponentFixture<RbdNamespaceFormModalComponent>;
+
+ configureTestBed({
+ imports: [
+ ReactiveFormsModule,
+ ComponentsModule,
+ HttpClientTestingModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule
+ ],
+ declarations: [RbdNamespaceFormModalComponent],
+ providers: [NgbActiveModal, AuthStorageService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdNamespaceFormModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.ts
new file mode 100644
index 000000000..bad32c3c5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-form/rbd-namespace-form-modal.component.ts
@@ -0,0 +1,144 @@
+import { Component, OnInit } from '@angular/core';
+import {
+ AbstractControl,
+ AsyncValidatorFn,
+ FormControl,
+ ValidationErrors,
+ ValidatorFn
+} from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { Subject } from 'rxjs';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-rbd-namespace-form-modal',
+ templateUrl: './rbd-namespace-form-modal.component.html',
+ styleUrls: ['./rbd-namespace-form-modal.component.scss']
+})
+export class RbdNamespaceFormModalComponent implements OnInit {
+ poolPermission: Permission;
+ pools: Array<Pool> = null;
+ pool: string;
+ namespace: string;
+
+ namespaceForm: CdFormGroup;
+
+ editing = false;
+
+ public onSubmit: Subject<void>;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private notificationService: NotificationService,
+ private poolService: PoolService,
+ private rbdService: RbdService
+ ) {
+ this.poolPermission = this.authStorageService.getPermissions().pool;
+ this.createForm();
+ }
+
+ createForm() {
+ this.namespaceForm = new CdFormGroup(
+ {
+ pool: new FormControl(''),
+ namespace: new FormControl('')
+ },
+ this.validator(),
+ this.asyncValidator()
+ );
+ }
+
+ validator(): ValidatorFn {
+ return (control: AbstractControl) => {
+ const poolCtrl = control.get('pool');
+ const namespaceCtrl = control.get('namespace');
+ let poolErrors = null;
+ if (!poolCtrl.value) {
+ poolErrors = { required: true };
+ }
+ poolCtrl.setErrors(poolErrors);
+ let namespaceErrors = null;
+ if (!namespaceCtrl.value) {
+ namespaceErrors = { required: true };
+ }
+ namespaceCtrl.setErrors(namespaceErrors);
+ return null;
+ };
+ }
+
+ asyncValidator(): AsyncValidatorFn {
+ return (control: AbstractControl): Promise<ValidationErrors | null> => {
+ return new Promise((resolve) => {
+ const poolCtrl = control.get('pool');
+ const namespaceCtrl = control.get('namespace');
+ this.rbdService.listNamespaces(poolCtrl.value).subscribe((namespaces: any[]) => {
+ if (namespaces.some((ns) => ns.namespace === namespaceCtrl.value)) {
+ const error = { namespaceExists: true };
+ namespaceCtrl.setErrors(error);
+ resolve(error);
+ } else {
+ resolve(null);
+ }
+ });
+ });
+ };
+ }
+
+ ngOnInit() {
+ this.onSubmit = new Subject();
+
+ if (this.poolPermission.read) {
+ this.poolService.list(['pool_name', 'type', 'application_metadata']).then((resp) => {
+ const pools: Pool[] = [];
+ for (const pool of resp) {
+ if (this.rbdService.isRBDPool(pool) && pool.type === 'replicated') {
+ pools.push(pool);
+ }
+ }
+ this.pools = pools;
+ if (this.pools.length === 1) {
+ const poolName = this.pools[0]['pool_name'];
+ this.namespaceForm.get('pool').setValue(poolName);
+ }
+ });
+ }
+ }
+
+ submit() {
+ const pool = this.namespaceForm.getValue('pool');
+ const namespace = this.namespaceForm.getValue('namespace');
+ const finishedTask = new FinishedTask();
+ finishedTask.name = 'rbd/namespace/create';
+ finishedTask.metadata = {
+ pool: pool,
+ namespace: namespace
+ };
+ this.rbdService
+ .createNamespace(pool, namespace)
+ .toPromise()
+ .then(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created namespace '${pool}/${namespace}'`
+ );
+ this.activeModal.close();
+ this.onSubmit.next();
+ })
+ .catch(() => {
+ this.namespaceForm.setErrors({ cdSubmitButton: true });
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.html
new file mode 100644
index 000000000..46e27179e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.html
@@ -0,0 +1,18 @@
+<cd-rbd-tabs></cd-rbd-tabs>
+
+<cd-table [data]="namespaces"
+ (fetchData)="refresh()"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="id"
+ forceIdentifier="true"
+ selectionType="single"
+ (updateSelection)="updateSelection($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions class="btn-group"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.spec.ts
new file mode 100644
index 000000000..85f8d3f81
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.spec.ts
@@ -0,0 +1,41 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdTabsComponent } from '../rbd-tabs/rbd-tabs.component';
+import { RbdNamespaceListComponent } from './rbd-namespace-list.component';
+
+describe('RbdNamespaceListComponent', () => {
+ let component: RbdNamespaceListComponent;
+ let fixture: ComponentFixture<RbdNamespaceListComponent>;
+
+ configureTestBed({
+ declarations: [RbdNamespaceListComponent, RbdTabsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ NgbNavModule
+ ],
+ providers: [TaskListService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdNamespaceListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.ts
new file mode 100644
index 000000000..4617e13e4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-namespace-list/rbd-namespace-list.component.ts
@@ -0,0 +1,157 @@
+import { Component, OnInit } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { forkJoin, Observable } from 'rxjs';
+
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+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 { TaskListService } from '~/app/shared/services/task-list.service';
+import { RbdNamespaceFormModalComponent } from '../rbd-namespace-form/rbd-namespace-form-modal.component';
+
+@Component({
+ selector: 'cd-rbd-namespace-list',
+ templateUrl: './rbd-namespace-list.component.html',
+ styleUrls: ['./rbd-namespace-list.component.scss'],
+ providers: [TaskListService]
+})
+export class RbdNamespaceListComponent implements OnInit {
+ columns: CdTableColumn[];
+ namespaces: any;
+ modalRef: NgbModalRef;
+ permission: Permission;
+ selection = new CdTableSelection();
+ tableActions: CdTableAction[];
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdService: RbdService,
+ private poolService: PoolService,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.permission = this.authStorageService.getPermissions().rbdImage;
+ const createAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ click: () => this.createModal(),
+ name: this.actionLabels.CREATE
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteModal(),
+ name: this.actionLabels.DELETE,
+ disable: () => this.getDeleteDisableDesc()
+ };
+ this.tableActions = [createAction, deleteAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Namespace`,
+ prop: 'namespace',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Pool`,
+ prop: 'pool',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Total images`,
+ prop: 'num_images',
+ flexGrow: 1
+ }
+ ];
+ this.refresh();
+ }
+
+ refresh() {
+ this.poolService.list(['pool_name', 'type', 'application_metadata']).then((pools: any) => {
+ pools = pools.filter(
+ (pool: any) => this.rbdService.isRBDPool(pool) && pool.type === 'replicated'
+ );
+ const promisses: Observable<any>[] = [];
+ pools.forEach((pool: any) => {
+ promisses.push(this.rbdService.listNamespaces(pool['pool_name']));
+ });
+ if (promisses.length > 0) {
+ forkJoin(promisses).subscribe((data: Array<Array<string>>) => {
+ const result: any[] = [];
+ for (let i = 0; i < data.length; i++) {
+ const namespaces = data[i];
+ const pool_name = pools[i]['pool_name'];
+ namespaces.forEach((namespace: any) => {
+ result.push({
+ id: `${pool_name}/${namespace.namespace}`,
+ pool: pool_name,
+ namespace: namespace.namespace,
+ num_images: namespace.num_images
+ });
+ });
+ }
+ this.namespaces = result;
+ });
+ } else {
+ this.namespaces = [];
+ }
+ });
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ createModal() {
+ this.modalRef = this.modalService.show(RbdNamespaceFormModalComponent);
+ this.modalRef.componentInstance.onSubmit.subscribe(() => {
+ this.refresh();
+ });
+ }
+
+ deleteModal() {
+ const pool = this.selection.first().pool;
+ const namespace = this.selection.first().namespace;
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'Namespace',
+ itemNames: [`${pool}/${namespace}`],
+ submitAction: () =>
+ this.rbdService.deleteNamespace(pool, namespace).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Deleted namespace '${pool}/${namespace}'`
+ );
+ this.modalRef.close();
+ this.refresh();
+ },
+ () => {
+ this.modalRef.componentInstance.stopLoadingSpinner();
+ }
+ )
+ });
+ }
+
+ getDeleteDisableDesc(): string | boolean {
+ const first = this.selection.first();
+
+ if (first?.num_images > 0) {
+ return $localize`Namespace contains images`;
+ }
+
+ return !this.selection?.first();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.html
new file mode 100644
index 000000000..002c8e57c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.html
@@ -0,0 +1,6 @@
+<cd-rbd-tabs></cd-rbd-tabs>
+
+<cd-grafana [grafanaPath]="'rbd-overview?'"
+ uid="41FrpeUiz"
+ grafanaStyle="two">
+</cd-grafana>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.spec.ts
new file mode 100644
index 000000000..d778d2552
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.spec.ts
@@ -0,0 +1,30 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdTabsComponent } from '../rbd-tabs/rbd-tabs.component';
+import { RbdPerformanceComponent } from './rbd-performance.component';
+
+describe('RbdPerformanceComponent', () => {
+ let component: RbdPerformanceComponent;
+ let fixture: ComponentFixture<RbdPerformanceComponent>;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, NgbNavModule],
+ declarations: [RbdPerformanceComponent, RbdTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdPerformanceComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.ts
new file mode 100644
index 000000000..76750a8ce
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-performance/rbd-performance.component.ts
@@ -0,0 +1,8 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'cd-rbd-performance',
+ templateUrl: './rbd-performance.component.html',
+ styleUrls: ['./rbd-performance.component.scss']
+})
+export class RbdPerformanceComponent {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.html
new file mode 100644
index 000000000..598e3fd38
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.html
@@ -0,0 +1,41 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="snapshotForm"
+ #formDir="ngForm"
+ [formGroup]="snapshotForm"
+ novalidate>
+ <div class="modal-body">
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="snapshotName"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Snapshot name..."
+ id="snapshotName"
+ name="snapshotName"
+ [attr.disabled]="(mirroring === 'snapshot') ? true : null"
+ formControlName="snapshotName"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="snapshotForm.showError('snapshotName', formDir, 'required')"
+ i18n>This field is required.</span><br><br>
+ <span *ngIf="mirroring === 'snapshot'"
+ i18n>Snapshot mode is enabled on image <b>{{ imageName }}</b>: snapshot names are auto generated</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="snapshotForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.spec.ts
new file mode 100644
index 000000000..b6681ec51
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.spec.ts
@@ -0,0 +1,62 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdSnapshotFormModalComponent } from './rbd-snapshot-form-modal.component';
+
+describe('RbdSnapshotFormModalComponent', () => {
+ let component: RbdSnapshotFormModalComponent;
+ let fixture: ComponentFixture<RbdSnapshotFormModalComponent>;
+
+ configureTestBed({
+ imports: [
+ ReactiveFormsModule,
+ ComponentsModule,
+ PipesModule,
+ HttpClientTestingModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule
+ ],
+ declarations: [RbdSnapshotFormModalComponent],
+ providers: [NgbActiveModal, AuthStorageService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdSnapshotFormModalComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should show "Create" text', () => {
+ fixture.detectChanges();
+
+ const header = fixture.debugElement.nativeElement.querySelector('h4');
+ expect(header.textContent).toBe('Create RBD Snapshot');
+
+ const button = fixture.debugElement.nativeElement.querySelector('cd-submit-button');
+ expect(button.textContent).toBe('Create RBD Snapshot');
+ });
+
+ it('should show "Rename" text', () => {
+ component.setEditing();
+
+ fixture.detectChanges();
+
+ const header = fixture.debugElement.nativeElement.querySelector('h4');
+ expect(header.textContent).toBe('Rename RBD Snapshot');
+
+ const button = fixture.debugElement.nativeElement.querySelector('cd-submit-button');
+ expect(button.textContent).toBe('Rename RBD Snapshot');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.ts
new file mode 100644
index 000000000..d5861163f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form-modal.component.ts
@@ -0,0 +1,137 @@
+import { Component } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { Subject } from 'rxjs';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TaskManagerService } from '~/app/shared/services/task-manager.service';
+
+@Component({
+ selector: 'cd-rbd-snapshot-form-modal',
+ templateUrl: './rbd-snapshot-form-modal.component.html',
+ styleUrls: ['./rbd-snapshot-form-modal.component.scss']
+})
+export class RbdSnapshotFormModalComponent {
+ poolName: string;
+ namespace: string;
+ imageName: string;
+ snapName: string;
+ mirroring: string;
+
+ snapshotForm: CdFormGroup;
+
+ editing = false;
+ action: string;
+ resource: string;
+
+ public onSubmit: Subject<string> = new Subject();
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private rbdService: RbdService,
+ private taskManagerService: TaskManagerService,
+ private notificationService: NotificationService,
+ private actionLabels: ActionLabelsI18n
+ ) {
+ this.action = this.actionLabels.CREATE;
+ this.resource = $localize`RBD Snapshot`;
+ this.createForm();
+ }
+
+ createForm() {
+ this.snapshotForm = new CdFormGroup({
+ snapshotName: new FormControl('', {
+ validators: [Validators.required]
+ })
+ });
+ }
+
+ setSnapName(snapName: string) {
+ this.snapName = snapName;
+ if (this.mirroring !== 'snapshot') {
+ this.snapshotForm.get('snapshotName').setValue(snapName);
+ } else {
+ this.snapshotForm.get('snapshotName').clearValidators();
+ }
+ }
+
+ /**
+ * Set the 'editing' flag. If set to TRUE, the modal dialog is in
+ * 'Edit' mode, otherwise in 'Create' mode.
+ * @param {boolean} editing
+ */
+ setEditing(editing: boolean = true) {
+ this.editing = editing;
+ this.action = this.editing ? this.actionLabels.RENAME : this.actionLabels.CREATE;
+ }
+
+ editAction() {
+ const snapshotName = this.snapshotForm.getValue('snapshotName');
+ const imageSpec = new ImageSpec(this.poolName, this.namespace, this.imageName);
+ const finishedTask = new FinishedTask();
+ finishedTask.name = 'rbd/snap/edit';
+ finishedTask.metadata = {
+ image_spec: imageSpec.toString(),
+ snapshot_name: snapshotName
+ };
+ this.rbdService
+ .renameSnapshot(imageSpec, this.snapName, snapshotName)
+ .toPromise()
+ .then(() => {
+ this.taskManagerService.subscribe(
+ finishedTask.name,
+ finishedTask.metadata,
+ (asyncFinishedTask: FinishedTask) => {
+ this.notificationService.notifyTask(asyncFinishedTask);
+ }
+ );
+ this.activeModal.close();
+ this.onSubmit.next(this.snapName);
+ })
+ .catch(() => {
+ this.snapshotForm.setErrors({ cdSubmitButton: true });
+ });
+ }
+
+ createAction() {
+ const snapshotName = this.snapshotForm.getValue('snapshotName');
+ const imageSpec = new ImageSpec(this.poolName, this.namespace, this.imageName);
+ const finishedTask = new FinishedTask();
+ finishedTask.name = 'rbd/snap/create';
+ finishedTask.metadata = {
+ image_spec: imageSpec.toString(),
+ snapshot_name: snapshotName
+ };
+ this.rbdService
+ .createSnapshot(imageSpec, snapshotName)
+ .toPromise()
+ .then(() => {
+ this.taskManagerService.subscribe(
+ finishedTask.name,
+ finishedTask.metadata,
+ (asyncFinishedTask: FinishedTask) => {
+ this.notificationService.notifyTask(asyncFinishedTask);
+ }
+ );
+ this.activeModal.close();
+ this.onSubmit.next(snapshotName);
+ })
+ .catch(() => {
+ this.snapshotForm.setErrors({ cdSubmitButton: true });
+ });
+ }
+
+ submit() {
+ if (this.editing) {
+ this.editAction();
+ } else {
+ this.createAction();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts
new file mode 100644
index 000000000..cc0d61f91
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts
@@ -0,0 +1,131 @@
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+
+export class RbdSnapshotActionsModel {
+ create: CdTableAction;
+ rename: CdTableAction;
+ protect: CdTableAction;
+ unprotect: CdTableAction;
+ clone: CdTableAction;
+ copy: CdTableAction;
+ rollback: CdTableAction;
+ deleteSnap: CdTableAction;
+ ordering: CdTableAction[];
+
+ cloneFormatVersion = 1;
+
+ constructor(
+ actionLabels: ActionLabelsI18n,
+ public featuresName: string[],
+ rbdService: RbdService
+ ) {
+ rbdService.cloneFormatVersion().subscribe((version: number) => {
+ this.cloneFormatVersion = version;
+ });
+
+ this.create = {
+ permission: 'create',
+ icon: Icons.add,
+ name: actionLabels.CREATE
+ };
+ this.rename = {
+ permission: 'update',
+ icon: Icons.edit,
+ name: actionLabels.RENAME,
+ disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
+ };
+ this.protect = {
+ permission: 'update',
+ icon: Icons.lock,
+ visible: (selection: CdTableSelection) =>
+ selection.hasSingleSelection && !selection.first().is_protected,
+ name: actionLabels.PROTECT,
+ disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
+ };
+ this.unprotect = {
+ permission: 'update',
+ icon: Icons.unlock,
+ visible: (selection: CdTableSelection) =>
+ selection.hasSingleSelection && selection.first().is_protected,
+ name: actionLabels.UNPROTECT,
+ disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
+ };
+ this.clone = {
+ permission: 'create',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
+ disable: (selection: CdTableSelection) =>
+ this.getCloneDisableDesc(selection, this.featuresName) ||
+ this.disableForMirrorSnapshot(selection),
+ icon: Icons.clone,
+ name: actionLabels.CLONE
+ };
+ this.copy = {
+ permission: 'create',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection ||
+ selection.first().cdExecuting ||
+ this.disableForMirrorSnapshot(selection),
+ icon: Icons.copy,
+ name: actionLabels.COPY
+ };
+ this.rollback = {
+ permission: 'update',
+ icon: Icons.undo,
+ name: actionLabels.ROLLBACK,
+ disable: (selection: CdTableSelection) => this.disableForMirrorSnapshot(selection)
+ };
+ this.deleteSnap = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ disable: (selection: CdTableSelection) => {
+ const first = selection.first();
+ return (
+ !selection.hasSingleSelection ||
+ first.cdExecuting ||
+ first.is_protected ||
+ this.disableForMirrorSnapshot(selection)
+ );
+ },
+ name: actionLabels.DELETE
+ };
+
+ this.ordering = [
+ this.create,
+ this.rename,
+ this.protect,
+ this.unprotect,
+ this.clone,
+ this.copy,
+ this.rollback,
+ this.deleteSnap
+ ];
+ }
+
+ getCloneDisableDesc(selection: CdTableSelection, featuresName: string[]): boolean | string {
+ if (selection.hasSingleSelection && !selection.first().cdExecuting) {
+ if (!featuresName?.includes('layering')) {
+ return $localize`Parent image must support Layering`;
+ }
+
+ if (this.cloneFormatVersion === 1 && !selection.first().is_protected) {
+ return $localize`Snapshot must be protected in order to clone.`;
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ disableForMirrorSnapshot(selection: CdTableSelection) {
+ return (
+ selection.hasSingleSelection &&
+ selection.first().mirror_mode === 'snapshot' &&
+ selection.first().name.includes('.mirror.')
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html
new file mode 100644
index 000000000..90fbf5384
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html
@@ -0,0 +1,17 @@
+<cd-table [data]="data"
+ columnMode="flex"
+ selectionType="single"
+ (updateSelection)="updateSelection($event)"
+ [columns]="columns">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+</cd-table>
+
+<ng-template #rollbackTpl
+ let-value>
+ <ng-container i18n>You are about to rollback</ng-container>
+ <strong> {{ value.snapName }}</strong>.
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts
new file mode 100644
index 000000000..ca72007ee
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts
@@ -0,0 +1,305 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbModalModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { MockComponent } from 'ng-mocks';
+import { ToastrModule } from 'ngx-toastr';
+import { Subject, throwError as observableThrowError } from 'rxjs';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { DataTableModule } from '~/app/shared/datatable/datatable.module';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { Permissions } from '~/app/shared/models/permissions';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+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 { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { configureTestBed, expectItemTasks, PermissionHelper } from '~/testing/unit-test-helper';
+import { RbdSnapshotFormModalComponent } from '../rbd-snapshot-form/rbd-snapshot-form-modal.component';
+import { RbdTabsComponent } from '../rbd-tabs/rbd-tabs.component';
+import { RbdSnapshotActionsModel } from './rbd-snapshot-actions.model';
+import { RbdSnapshotListComponent } from './rbd-snapshot-list.component';
+import { RbdSnapshotModel } from './rbd-snapshot.model';
+
+describe('RbdSnapshotListComponent', () => {
+ let component: RbdSnapshotListComponent;
+ let fixture: ComponentFixture<RbdSnapshotListComponent>;
+ let summaryService: SummaryService;
+
+ const fakeAuthStorageService = {
+ isLoggedIn: () => {
+ return true;
+ },
+ getPermissions: () => {
+ return new Permissions({ 'rbd-image': ['read', 'update', 'create', 'delete'] });
+ }
+ };
+
+ configureTestBed(
+ {
+ declarations: [
+ RbdSnapshotListComponent,
+ RbdTabsComponent,
+ MockComponent(RbdSnapshotFormModalComponent)
+ ],
+ imports: [
+ BrowserAnimationsModule,
+ ComponentsModule,
+ DataTableModule,
+ HttpClientTestingModule,
+ PipesModule,
+ RouterTestingModule,
+ NgbNavModule,
+ ToastrModule.forRoot(),
+ NgbModalModule
+ ],
+ providers: [
+ { provide: AuthStorageService, useValue: fakeAuthStorageService },
+ TaskListService
+ ]
+ },
+ [CriticalConfirmationModalComponent]
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdSnapshotListComponent);
+ component = fixture.componentInstance;
+ component.ngOnChanges();
+ summaryService = TestBed.inject(SummaryService);
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ describe('api delete request', () => {
+ let called: boolean;
+ let rbdService: RbdService;
+ let notificationService: NotificationService;
+ let authStorageService: AuthStorageService;
+
+ beforeEach(() => {
+ fixture.detectChanges();
+ const modalService = TestBed.inject(ModalService);
+ const actionLabelsI18n = TestBed.inject(ActionLabelsI18n);
+ called = false;
+ rbdService = new RbdService(null, null);
+ notificationService = new NotificationService(null, null, null);
+ authStorageService = new AuthStorageService();
+ authStorageService.set('user', { 'rbd-image': ['create', 'read', 'update', 'delete'] });
+ component = new RbdSnapshotListComponent(
+ authStorageService,
+ modalService,
+ null,
+ null,
+ rbdService,
+ null,
+ notificationService,
+ null,
+ null,
+ actionLabelsI18n,
+ null
+ );
+ spyOn(rbdService, 'deleteSnapshot').and.returnValue(observableThrowError({ status: 500 }));
+ spyOn(notificationService, 'notifyTask').and.stub();
+ });
+
+ it('should call stopLoadingSpinner if the request fails', fakeAsync(() => {
+ component.updateSelection(new CdTableSelection([{ name: 'someName' }]));
+ expect(called).toBe(false);
+ component.deleteSnapshotModal();
+ spyOn(component.modalRef.componentInstance, 'stopLoadingSpinner').and.callFake(() => {
+ called = true;
+ });
+ component.modalRef.componentInstance.submitAction();
+ tick(500);
+ expect(called).toBe(true);
+ }));
+ });
+
+ describe('handling of executing tasks', () => {
+ let snapshots: RbdSnapshotModel[];
+
+ const addSnapshot = (name: string) => {
+ const model = new RbdSnapshotModel();
+ model.id = 1;
+ model.name = name;
+ snapshots.push(model);
+ };
+
+ const addTask = (task_name: string, snapshot_name: string) => {
+ const task = new ExecutingTask();
+ task.name = task_name;
+ task.metadata = {
+ image_spec: 'rbd/foo',
+ snapshot_name: snapshot_name
+ };
+ summaryService.addRunningTask(task);
+ };
+
+ const refresh = (data: any) => {
+ summaryService['summaryDataSource'].next(data);
+ };
+
+ beforeEach(() => {
+ fixture.detectChanges();
+ snapshots = [];
+ addSnapshot('a');
+ addSnapshot('b');
+ addSnapshot('c');
+ component.snapshots = snapshots;
+ component.poolName = 'rbd';
+ component.rbdName = 'foo';
+ refresh({ executing_tasks: [], finished_tasks: [] });
+ component.ngOnChanges();
+ fixture.detectChanges();
+ });
+
+ it('should gets all snapshots without tasks', () => {
+ expect(component.snapshots.length).toBe(3);
+ expect(component.snapshots.every((image) => !image.cdExecuting)).toBeTruthy();
+ });
+
+ it('should add a new image from a task', () => {
+ addTask('rbd/snap/create', 'd');
+ expect(component.snapshots.length).toBe(4);
+ expectItemTasks(component.snapshots[0], undefined);
+ expectItemTasks(component.snapshots[1], undefined);
+ expectItemTasks(component.snapshots[2], undefined);
+ expectItemTasks(component.snapshots[3], 'Creating');
+ });
+
+ it('should show when an existing image is being modified', () => {
+ addTask('rbd/snap/edit', 'a');
+ addTask('rbd/snap/delete', 'b');
+ addTask('rbd/snap/rollback', 'c');
+ expect(component.snapshots.length).toBe(3);
+ expectItemTasks(component.snapshots[0], 'Updating');
+ expectItemTasks(component.snapshots[1], 'Deleting');
+ expectItemTasks(component.snapshots[2], 'Rolling back');
+ });
+ });
+
+ describe('snapshot modal dialog', () => {
+ beforeEach(() => {
+ component.poolName = 'pool01';
+ component.rbdName = 'image01';
+ spyOn(TestBed.inject(ModalService), 'show').and.callFake(() => {
+ const ref: any = {};
+ ref.componentInstance = new RbdSnapshotFormModalComponent(
+ null,
+ null,
+ null,
+ null,
+ TestBed.inject(ActionLabelsI18n)
+ );
+ ref.componentInstance.onSubmit = new Subject();
+ return ref;
+ });
+ });
+
+ it('should display old snapshot name', () => {
+ component.selection.selected = [{ name: 'oldname' }];
+ component.openEditSnapshotModal();
+ expect(component.modalRef.componentInstance.snapName).toBe('oldname');
+ expect(component.modalRef.componentInstance.editing).toBeTruthy();
+ });
+
+ it('should display suggested snapshot name', () => {
+ component.openCreateSnapshotModal();
+ expect(component.modalRef.componentInstance.snapName).toMatch(
+ RegExp(`^${component.rbdName}_[\\d-]+T[\\d.:]+[\\+-][\\d:]+$`)
+ );
+ });
+ });
+
+ it('should test all TableActions combinations', () => {
+ component.ngOnInit();
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: [
+ 'Create',
+ 'Rename',
+ 'Protect',
+ 'Unprotect',
+ 'Clone',
+ 'Copy',
+ 'Rollback',
+ 'Delete'
+ ],
+ primary: { multiple: 'Create', executing: 'Rename', single: 'Rename', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Rename', 'Protect', 'Unprotect', 'Clone', 'Copy', 'Rollback'],
+ primary: { multiple: 'Create', executing: 'Rename', single: 'Rename', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Clone', 'Copy', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Clone', single: 'Clone', no: 'Create' }
+ },
+ create: {
+ actions: ['Create', 'Clone', 'Copy'],
+ primary: { multiple: 'Create', executing: 'Clone', single: 'Clone', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Rename', 'Protect', 'Unprotect', 'Rollback', 'Delete'],
+ primary: { multiple: 'Rename', executing: 'Rename', single: 'Rename', no: 'Rename' }
+ },
+ update: {
+ actions: ['Rename', 'Protect', 'Unprotect', 'Rollback'],
+ primary: { multiple: 'Rename', executing: 'Rename', single: 'Rename', no: 'Rename' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ describe('clone button disable state', () => {
+ let actions: RbdSnapshotActionsModel;
+
+ beforeEach(() => {
+ fixture.detectChanges();
+ const rbdService = TestBed.inject(RbdService);
+ const actionLabelsI18n = TestBed.inject(ActionLabelsI18n);
+ actions = new RbdSnapshotActionsModel(actionLabelsI18n, [], rbdService);
+ });
+
+ it('should be disabled with version 1 and protected false', () => {
+ const selection = new CdTableSelection([{ name: 'someName', is_protected: false }]);
+ const disableDesc = actions.getCloneDisableDesc(selection, ['layering']);
+ expect(disableDesc).toBe('Snapshot must be protected in order to clone.');
+ });
+
+ it.each([
+ [1, true],
+ [2, true],
+ [2, false]
+ ])('should be enabled with version %d and protected %s', (version, is_protected) => {
+ actions.cloneFormatVersion = version;
+ const selection = new CdTableSelection([{ name: 'someName', is_protected: is_protected }]);
+ const disableDesc = actions.getCloneDisableDesc(selection, ['layering']);
+ expect(disableDesc).toBe(false);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts
new file mode 100644
index 000000000..df66b0e88
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts
@@ -0,0 +1,336 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ Input,
+ OnChanges,
+ OnInit,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import moment from 'moment';
+import { of } from 'rxjs';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { CdHelperClass } from '~/app/shared/classes/cd-helper.class';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { Permission } from '~/app/shared/models/permissions';
+import { Task } from '~/app/shared/models/task';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+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 { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { TaskManagerService } from '~/app/shared/services/task-manager.service';
+import { RbdSnapshotFormModalComponent } from '../rbd-snapshot-form/rbd-snapshot-form-modal.component';
+import { RbdSnapshotActionsModel } from './rbd-snapshot-actions.model';
+import { RbdSnapshotModel } from './rbd-snapshot.model';
+
+@Component({
+ selector: 'cd-rbd-snapshot-list',
+ templateUrl: './rbd-snapshot-list.component.html',
+ styleUrls: ['./rbd-snapshot-list.component.scss'],
+ providers: [TaskListService],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class RbdSnapshotListComponent implements OnInit, OnChanges {
+ @Input()
+ snapshots: RbdSnapshotModel[] = [];
+ @Input()
+ featuresName: string[];
+ @Input()
+ poolName: string;
+ @Input()
+ namespace: string;
+ @Input()
+ mirroring: string;
+ @Input()
+ rbdName: string;
+ @ViewChild('nameTpl')
+ nameTpl: TemplateRef<any>;
+ @ViewChild('rollbackTpl', { static: true })
+ rollbackTpl: TemplateRef<any>;
+
+ permission: Permission;
+ selection = new CdTableSelection();
+ tableActions: CdTableAction[];
+ rbdTableActions: RbdSnapshotActionsModel;
+ imageSpec: ImageSpec;
+
+ data: RbdSnapshotModel[];
+
+ columns: CdTableColumn[];
+
+ modalRef: NgbModalRef;
+
+ builders = {
+ 'rbd/snap/create': (metadata: any) => {
+ const model = new RbdSnapshotModel();
+ model.name = metadata['snapshot_name'];
+ return model;
+ }
+ };
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private modalService: ModalService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private cdDatePipe: CdDatePipe,
+ private rbdService: RbdService,
+ private taskManagerService: TaskManagerService,
+ private notificationService: NotificationService,
+ private summaryService: SummaryService,
+ private taskListService: TaskListService,
+ private actionLabels: ActionLabelsI18n,
+ private cdr: ChangeDetectorRef
+ ) {
+ this.permission = this.authStorageService.getPermissions().rbdImage;
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ cellTransformation: CellTemplate.executing,
+ flexGrow: 2
+ },
+ {
+ name: $localize`Size`,
+ prop: 'size',
+ flexGrow: 1,
+ cellClass: 'text-right',
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`Provisioned`,
+ prop: 'disk_usage',
+ flexGrow: 1,
+ cellClass: 'text-right',
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`State`,
+ prop: 'is_protected',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ true: { value: $localize`PROTECTED`, class: 'badge-success' },
+ false: { value: $localize`UNPROTECTED`, class: 'badge-info' }
+ }
+ }
+ },
+ {
+ name: $localize`Created`,
+ prop: 'timestamp',
+ flexGrow: 1,
+ pipe: this.cdDatePipe
+ }
+ ];
+
+ this.imageSpec = new ImageSpec(this.poolName, this.namespace, this.rbdName);
+ this.rbdTableActions = new RbdSnapshotActionsModel(
+ this.actionLabels,
+ this.featuresName,
+ this.rbdService
+ );
+ this.rbdTableActions.create.click = () => this.openCreateSnapshotModal();
+ this.rbdTableActions.rename.click = () => this.openEditSnapshotModal();
+ this.rbdTableActions.protect.click = () => this.toggleProtection();
+ this.rbdTableActions.unprotect.click = () => this.toggleProtection();
+ const getImageUri = () =>
+ this.selection.first() &&
+ `${this.imageSpec.toStringEncoded()}/${encodeURIComponent(this.selection.first().name)}`;
+ this.rbdTableActions.clone.routerLink = () => `/block/rbd/clone/${getImageUri()}`;
+ this.rbdTableActions.copy.routerLink = () => `/block/rbd/copy/${getImageUri()}`;
+ this.rbdTableActions.rollback.click = () => this.rollbackModal();
+ this.rbdTableActions.deleteSnap.click = () => this.deleteSnapshotModal();
+
+ this.tableActions = this.rbdTableActions.ordering;
+
+ const itemFilter = (entry: any, task: Task) => {
+ return entry.name === task.metadata['snapshot_name'];
+ };
+
+ const taskFilter = (task: Task) => {
+ return (
+ ['rbd/snap/create', 'rbd/snap/delete', 'rbd/snap/edit', 'rbd/snap/rollback'].includes(
+ task.name
+ ) && this.imageSpec.toString() === task.metadata['image_spec']
+ );
+ };
+
+ this.taskListService.init(
+ () => of(this.snapshots),
+ null,
+ (items) => {
+ const hasChanges = CdHelperClass.updateChanged(this, {
+ data: items
+ });
+ if (hasChanges) {
+ this.cdr.detectChanges();
+ this.data = [...this.data];
+ }
+ },
+ () => {
+ const hasChanges = CdHelperClass.updateChanged(this, {
+ data: this.snapshots
+ });
+ if (hasChanges) {
+ this.cdr.detectChanges();
+ this.data = [...this.data];
+ }
+ },
+ taskFilter,
+ itemFilter,
+ this.builders
+ );
+ }
+
+ ngOnChanges() {
+ if (this.columns) {
+ this.imageSpec = new ImageSpec(this.poolName, this.namespace, this.rbdName);
+ if (this.rbdTableActions) {
+ this.rbdTableActions.featuresName = this.featuresName;
+ }
+ this.taskListService.fetch();
+ }
+ }
+
+ private openSnapshotModal(taskName: string, snapName: string = null) {
+ const modalVariables = {
+ mirroring: this.mirroring
+ };
+ this.modalRef = this.modalService.show(RbdSnapshotFormModalComponent, modalVariables);
+ this.modalRef.componentInstance.poolName = this.poolName;
+ this.modalRef.componentInstance.imageName = this.rbdName;
+ this.modalRef.componentInstance.namespace = this.namespace;
+ if (snapName) {
+ this.modalRef.componentInstance.setEditing();
+ } else {
+ // Auto-create a name for the snapshot: <image_name>_<timestamp_ISO_8601>
+ // https://en.wikipedia.org/wiki/ISO_8601
+ snapName = `${this.rbdName}_${moment().toISOString(true)}`;
+ }
+ this.modalRef.componentInstance.setSnapName(snapName);
+ this.modalRef.componentInstance.onSubmit.subscribe((snapshotName: string) => {
+ const executingTask = new ExecutingTask();
+ executingTask.name = taskName;
+ executingTask.metadata = {
+ image_spec: this.imageSpec.toString(),
+ snapshot_name: snapshotName
+ };
+ this.summaryService.addRunningTask(executingTask);
+ });
+ }
+
+ openCreateSnapshotModal() {
+ this.openSnapshotModal('rbd/snap/create');
+ }
+
+ openEditSnapshotModal() {
+ this.openSnapshotModal('rbd/snap/edit', this.selection.first().name);
+ }
+
+ toggleProtection() {
+ const snapshotName = this.selection.first().name;
+ const isProtected = this.selection.first().is_protected;
+ const finishedTask = new FinishedTask();
+ finishedTask.name = 'rbd/snap/edit';
+ const imageSpec = new ImageSpec(this.poolName, this.namespace, this.rbdName);
+ finishedTask.metadata = {
+ image_spec: imageSpec.toString(),
+ snapshot_name: snapshotName
+ };
+ this.rbdService
+ .protectSnapshot(imageSpec, snapshotName, !isProtected)
+ .toPromise()
+ .then(() => {
+ const executingTask = new ExecutingTask();
+ executingTask.name = finishedTask.name;
+ executingTask.metadata = finishedTask.metadata;
+ this.summaryService.addRunningTask(executingTask);
+ this.taskManagerService.subscribe(
+ finishedTask.name,
+ finishedTask.metadata,
+ (asyncFinishedTask: FinishedTask) => {
+ this.notificationService.notifyTask(asyncFinishedTask);
+ }
+ );
+ });
+ }
+
+ _asyncTask(task: string, taskName: string, snapshotName: string) {
+ const finishedTask = new FinishedTask();
+ finishedTask.name = taskName;
+ finishedTask.metadata = {
+ image_spec: new ImageSpec(this.poolName, this.namespace, this.rbdName).toString(),
+ snapshot_name: snapshotName
+ };
+ const imageSpec = new ImageSpec(this.poolName, this.namespace, this.rbdName);
+ this.rbdService[task](imageSpec, snapshotName)
+ .toPromise()
+ .then(() => {
+ const executingTask = new ExecutingTask();
+ executingTask.name = finishedTask.name;
+ executingTask.metadata = finishedTask.metadata;
+ this.summaryService.addRunningTask(executingTask);
+ this.modalRef.close();
+ this.taskManagerService.subscribe(
+ executingTask.name,
+ executingTask.metadata,
+ (asyncFinishedTask: FinishedTask) => {
+ this.notificationService.notifyTask(asyncFinishedTask);
+ }
+ );
+ })
+ .catch(() => {
+ this.modalRef.componentInstance.stopLoadingSpinner();
+ });
+ }
+
+ rollbackModal() {
+ const snapshotName = this.selection.selected[0].name;
+ const imageSpec = new ImageSpec(this.poolName, this.namespace, this.rbdName).toString();
+ const initialState = {
+ titleText: $localize`RBD snapshot rollback`,
+ buttonText: $localize`Rollback`,
+ bodyTpl: this.rollbackTpl,
+ bodyData: {
+ snapName: `${imageSpec}@${snapshotName}`
+ },
+ onSubmit: () => {
+ this._asyncTask('rollbackSnapshot', 'rbd/snap/rollback', snapshotName);
+ }
+ };
+
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, initialState);
+ }
+
+ deleteSnapshotModal() {
+ const snapshotName = this.selection.selected[0].name;
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: $localize`RBD snapshot`,
+ itemNames: [snapshotName],
+ submitAction: () => this._asyncTask('deleteSnapshot', 'rbd/snap/delete', snapshotName)
+ });
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts
new file mode 100644
index 000000000..06fd28783
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts
@@ -0,0 +1,9 @@
+export class RbdSnapshotModel {
+ id: number;
+ name: string;
+ size: number;
+ timestamp: string;
+ is_protected: boolean;
+
+ cdExecuting: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.html
new file mode 100644
index 000000000..657568c22
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.html
@@ -0,0 +1,23 @@
+<ul ngbNav
+ #nav="ngbNav"
+ [activeId]="router.url"
+ (navChange)="router.navigate([$event.nextId])"
+ class="nav-tabs">
+ <li ngbNavItem="/block/rbd">
+ <a ngbNavLink
+ i18n>Images</a>
+ </li>
+ <li ngbNavItem="/block/rbd/namespaces">
+ <a ngbNavLink
+ i18n>Namespaces</a>
+ </li>
+ <li ngbNavItem="/block/rbd/trash">
+ <a ngbNavLink
+ i18n>Trash</a>
+ </li>
+ <li ngbNavItem="/block/rbd/performance"
+ *ngIf="grafanaPermission.read">
+ <a ngbNavLink
+ i18n>Overall Performance</a>
+ </li>
+</ul>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.spec.ts
new file mode 100644
index 000000000..73a9490d7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.spec.ts
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdTabsComponent } from './rbd-tabs.component';
+
+describe('RbdTabsComponent', () => {
+ let component: RbdTabsComponent;
+ let fixture: ComponentFixture<RbdTabsComponent>;
+
+ configureTestBed({
+ imports: [RouterTestingModule, NgbNavModule],
+ declarations: [RbdTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTabsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.ts
new file mode 100644
index 000000000..056cb1764
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-tabs/rbd-tabs.component.ts
@@ -0,0 +1,19 @@
+import { Component } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-rbd-tabs',
+ templateUrl: './rbd-tabs.component.html',
+ styleUrls: ['./rbd-tabs.component.scss']
+})
+export class RbdTabsComponent {
+ grafanaPermission: Permission;
+ url: string;
+
+ constructor(private authStorageService: AuthStorageService, public router: Router) {
+ this.grafanaPermission = this.authStorageService.getPermissions().grafana;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html
new file mode 100644
index 000000000..044a1e9ac
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html
@@ -0,0 +1,52 @@
+<cd-rbd-tabs></cd-rbd-tabs>
+
+<cd-table [data]="images"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="id"
+ forceIdentifier="true"
+ selectionType="single"
+ [status]="tableStatus"
+ [autoReload]="-1"
+ (fetchData)="taskListService.fetch()"
+ (updateSelection)="updateSelection($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions class="btn-group"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <button class="btn btn-light"
+ type="button"
+ (click)="purgeModal()"
+ [disabled]="disablePurgeBtn"
+ *ngIf="permission.delete">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ <ng-container i18n>Purge Trash</ng-container>
+ </button>
+ </div>
+</cd-table>
+
+<ng-template #expiresTpl
+ let-row="row"
+ let-value="value">
+ <ng-container *ngIf="row.cdIsExpired"
+ i18n>Expired at</ng-container>
+
+ <ng-container *ngIf="!row.cdIsExpired"
+ i18n>Protected until</ng-container>
+
+ {{ value | cdDate }}
+</ng-template>
+
+<ng-template #deleteTpl
+ let-expiresAt="expiresAt"
+ let-isExpired="isExpired">
+ <p class="text-danger"
+ *ngIf="!isExpired">
+ <strong>
+ <ng-container i18n>This image is protected until {{ expiresAt | cdDate }}.</ng-container>
+ </strong>
+ </p>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts
new file mode 100644
index 000000000..17d8eed0f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts
@@ -0,0 +1,172 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import moment from 'moment';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { Summary } from '~/app/shared/models/summary.model';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, expectItemTasks } from '~/testing/unit-test-helper';
+import { RbdTabsComponent } from '../rbd-tabs/rbd-tabs.component';
+import { RbdTrashListComponent } from './rbd-trash-list.component';
+
+describe('RbdTrashListComponent', () => {
+ let component: RbdTrashListComponent;
+ let fixture: ComponentFixture<RbdTrashListComponent>;
+ let summaryService: SummaryService;
+ let rbdService: RbdService;
+
+ configureTestBed({
+ declarations: [RbdTrashListComponent, RbdTabsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ NgbNavModule,
+ NgxPipeFunctionModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [TaskListService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashListComponent);
+ component = fixture.componentInstance;
+ summaryService = TestBed.inject(SummaryService);
+ rbdService = TestBed.inject(RbdService);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should load trash images when summary is trigged', () => {
+ spyOn(rbdService, 'listTrash').and.callThrough();
+
+ summaryService['summaryDataSource'].next(new Summary());
+ expect(rbdService.listTrash).toHaveBeenCalled();
+ });
+
+ it('should call updateSelection', () => {
+ expect(component.selection.hasSelection).toBeFalsy();
+ component.updateSelection(new CdTableSelection(['foo']));
+ expect(component.selection.hasSelection).toBeTruthy();
+ });
+
+ describe('handling of executing tasks', () => {
+ let images: any[];
+
+ const addImage = (id: string) => {
+ images.push({
+ id: id,
+ pool_name: 'pl'
+ });
+ };
+
+ const addTask = (name: string, image_id: string) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ task.metadata = {
+ image_id_spec: `pl/${image_id}`
+ };
+ summaryService.addRunningTask(task);
+ };
+
+ beforeEach(() => {
+ images = [];
+ addImage('1');
+ addImage('2');
+ component.images = images;
+ summaryService['summaryDataSource'].next(new Summary());
+ spyOn(rbdService, 'listTrash').and.callFake(() =>
+ of([{ pool_name: 'rbd', status: 1, value: images }])
+ );
+ fixture.detectChanges();
+ });
+
+ it('should gets all images without tasks', () => {
+ expect(component.images.length).toBe(2);
+ expect(
+ component.images.every((image: Record<string, any>) => !image.cdExecuting)
+ ).toBeTruthy();
+ });
+
+ it('should show when an existing image is being modified', () => {
+ addTask('rbd/trash/remove', '1');
+ addTask('rbd/trash/restore', '2');
+ expect(component.images.length).toBe(2);
+ expectItemTasks(component.images[0], 'Deleting');
+ expectItemTasks(component.images[1], 'Restoring');
+ });
+ });
+
+ describe('display purge button', () => {
+ let images: any[];
+ const addImage = (id: string) => {
+ images.push({
+ id: id,
+ pool_name: 'pl',
+ deferment_end_time: moment()
+ });
+ };
+
+ beforeEach(() => {
+ summaryService['summaryDataSource'].next(new Summary());
+ spyOn(rbdService, 'listTrash').and.callFake(() => {
+ of([{ pool_name: 'rbd', status: 1, value: images }]);
+ });
+ fixture.detectChanges();
+ });
+
+ it('should show button disabled when no image is in trash', () => {
+ expect(component.disablePurgeBtn).toBeTruthy();
+ });
+
+ it('should show button enabled when an existing image is in trash', () => {
+ images = [];
+ addImage('1');
+ const payload = [{ pool_name: 'rbd', status: 1, value: images }];
+ component.prepareResponse(payload);
+ expect(component.disablePurgeBtn).toBeFalsy();
+ });
+
+ it('should show button with delete permission', () => {
+ component.permission = {
+ read: true,
+ create: true,
+ delete: true,
+ update: true
+ };
+ fixture.detectChanges();
+
+ const purge = fixture.debugElement.query(By.css('.table-actions button .fa-times'));
+ expect(purge).not.toBeNull();
+ });
+
+ it('should remove button without delete permission', () => {
+ component.permission = {
+ read: true,
+ create: true,
+ delete: false,
+ update: true
+ };
+ fixture.detectChanges();
+
+ const purge = fixture.debugElement.query(By.css('.table-actions button .fa-times'));
+ expect(purge).toBeNull();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts
new file mode 100644
index 000000000..43fe42b99
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts
@@ -0,0 +1,225 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import moment from 'moment';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } 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';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { Permission } from '~/app/shared/models/permissions';
+import { Task } from '~/app/shared/models/task';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { RbdTrashPurgeModalComponent } from '../rbd-trash-purge-modal/rbd-trash-purge-modal.component';
+import { RbdTrashRestoreModalComponent } from '../rbd-trash-restore-modal/rbd-trash-restore-modal.component';
+
+@Component({
+ selector: 'cd-rbd-trash-list',
+ templateUrl: './rbd-trash-list.component.html',
+ styleUrls: ['./rbd-trash-list.component.scss'],
+ providers: [TaskListService]
+})
+export class RbdTrashListComponent implements OnInit {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+ @ViewChild('expiresTpl', { static: true })
+ expiresTpl: TemplateRef<any>;
+ @ViewChild('deleteTpl', { static: true })
+ deleteTpl: TemplateRef<any>;
+
+ icons = Icons;
+
+ columns: CdTableColumn[];
+ executingTasks: ExecutingTask[] = [];
+ images: any;
+ modalRef: NgbModalRef;
+ permission: Permission;
+ retries: number;
+ selection = new CdTableSelection();
+ tableActions: CdTableAction[];
+ tableStatus = new TableStatusViewCache();
+ disablePurgeBtn = true;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdService: RbdService,
+ private modalService: ModalService,
+ private cdDatePipe: CdDatePipe,
+ public taskListService: TaskListService,
+ private taskWrapper: TaskWrapperService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.permission = this.authStorageService.getPermissions().rbdImage;
+ const restoreAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.undo,
+ click: () => this.restoreModal(),
+ name: this.actionLabels.RESTORE
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteModal(),
+ name: this.actionLabels.DELETE
+ };
+ this.tableActions = [restoreAction, deleteAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`ID`,
+ prop: 'id',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.executing
+ },
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Pool`,
+ prop: 'pool_name',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Namespace`,
+ prop: 'namespace',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Status`,
+ prop: 'deferment_end_time',
+ flexGrow: 1,
+ cellTemplate: this.expiresTpl
+ },
+ {
+ name: $localize`Deleted At`,
+ prop: 'deletion_time',
+ flexGrow: 1,
+ pipe: this.cdDatePipe
+ }
+ ];
+
+ const itemFilter = (entry: any, task: Task) => {
+ const imageSpec = new ImageSpec(entry.pool_name, entry.namespace, entry.id);
+ return imageSpec.toString() === task.metadata['image_id_spec'];
+ };
+
+ const taskFilter = (task: Task) => {
+ return ['rbd/trash/remove', 'rbd/trash/restore'].includes(task.name);
+ };
+
+ this.taskListService.init(
+ () => this.rbdService.listTrash(),
+ (resp) => this.prepareResponse(resp),
+ (images) => (this.images = images),
+ () => this.onFetchError(),
+ taskFilter,
+ itemFilter,
+ undefined
+ );
+ }
+
+ prepareResponse(resp: any[]): any[] {
+ let images: any[] = [];
+ const viewCacheStatusMap = {};
+
+ resp.forEach((pool: Record<string, any>) => {
+ if (_.isUndefined(viewCacheStatusMap[pool.status])) {
+ viewCacheStatusMap[pool.status] = [];
+ }
+ viewCacheStatusMap[pool.status].push(pool.pool_name);
+ images = images.concat(pool.value);
+ this.disablePurgeBtn = !images.length;
+ });
+
+ let status: number;
+ if (viewCacheStatusMap[3]) {
+ status = 3;
+ } else if (viewCacheStatusMap[1]) {
+ status = 1;
+ } else if (viewCacheStatusMap[2]) {
+ status = 2;
+ }
+
+ if (status) {
+ const statusFor =
+ (viewCacheStatusMap[status].length > 1 ? 'pools ' : 'pool ') +
+ viewCacheStatusMap[status].join();
+
+ this.tableStatus = new TableStatusViewCache(status, statusFor);
+ } else {
+ this.tableStatus = new TableStatusViewCache();
+ }
+
+ images.forEach((image) => {
+ image.cdIsExpired = moment().isAfter(image.deferment_end_time);
+ });
+
+ return images;
+ }
+
+ onFetchError() {
+ this.table.reset(); // Disable loading indicator.
+ this.tableStatus = new TableStatusViewCache(ViewCacheStatus.ValueException);
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ restoreModal() {
+ const initialState = {
+ poolName: this.selection.first().pool_name,
+ namespace: this.selection.first().namespace,
+ imageName: this.selection.first().name,
+ imageId: this.selection.first().id
+ };
+
+ this.modalRef = this.modalService.show(RbdTrashRestoreModalComponent, initialState);
+ }
+
+ deleteModal() {
+ const poolName = this.selection.first().pool_name;
+ const namespace = this.selection.first().namespace;
+ const imageId = this.selection.first().id;
+ const expiresAt = this.selection.first().deferment_end_time;
+ const isExpired = moment().isAfter(expiresAt);
+ const imageIdSpec = new ImageSpec(poolName, namespace, imageId);
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'RBD',
+ itemNames: [imageIdSpec],
+ bodyTemplate: this.deleteTpl,
+ bodyContext: { expiresAt, isExpired },
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/remove', {
+ image_id_spec: imageIdSpec.toString()
+ }),
+ call: this.rbdService.removeTrash(imageIdSpec, true)
+ })
+ });
+ }
+
+ purgeModal() {
+ this.modalService.show(RbdTrashPurgeModalComponent);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html
new file mode 100644
index 000000000..00c3f9265
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html
@@ -0,0 +1,57 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n
+ class="modal-title">Move an image to trash</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="moveForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="moveForm"
+ novalidate>
+ <div class="modal-body">
+ <div class="alert alert-warning"
+ *ngIf="hasSnapshots"
+ role="alert">
+ <span i18n>This image contains snapshot(s), which will prevent it
+ from being removed after moved to trash.</span>
+ </div>
+
+ <p i18n>To move <kbd>{{ imageSpecStr }}</kbd> to trash,
+ click <kbd>Move</kbd>. Optionally, you can pick an expiration date.</p>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="expiresAt"
+ i18n>Protection expires at</label>
+ <input type="text"
+ placeholder="NOT PROTECTED"
+ i18n-placeholder
+ class="form-control"
+ formControlName="expiresAt"
+ [ngbPopover]="popContent"
+ triggers="manual"
+ #p="ngbPopover"
+ (click)="p.open()"
+ (keypress)="p.close()">
+
+ <span class="invalid-feedback"
+ *ngIf="moveForm.showError('expiresAt', formDir, 'format')"
+ i18n>Wrong date format. Please use "YYYY-MM-DD HH:mm:ss".</span>
+ <span class="invalid-feedback"
+ *ngIf="moveForm.showError('expiresAt', formDir, 'expired')"
+ i18n>Protection has already expired. Please pick a future date or leave it empty.</span>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="moveImage()"
+ [form]="moveForm"
+ [submitText]="actionLabels.MOVE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
+
+<ng-template #popContent>
+ <cd-date-time-picker [control]="moveForm.get('expiresAt')"></cd-date-time-picker>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts
new file mode 100644
index 000000000..0381046b7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts
@@ -0,0 +1,94 @@
+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, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+import moment from 'moment';
+import { ToastrModule } from 'ngx-toastr';
+
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal.component';
+
+describe('RbdTrashMoveModalComponent', () => {
+ let component: RbdTrashMoveModalComponent;
+ let fixture: ComponentFixture<RbdTrashMoveModalComponent>;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbPopoverModule
+ ],
+ declarations: [RbdTrashMoveModalComponent],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashMoveModalComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.inject(HttpTestingController);
+
+ component.poolName = 'foo';
+ component.imageName = 'bar';
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.moveForm).toBeDefined();
+ });
+
+ it('should finish running ngOnInit', () => {
+ expect(component.pattern).toEqual('foo/bar');
+ });
+
+ describe('should call moveImage', () => {
+ let notificationService: NotificationService;
+
+ beforeEach(() => {
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+ spyOn(component.activeModal, 'close').and.callThrough();
+ });
+
+ afterEach(() => {
+ expect(notificationService.show).toHaveBeenCalledTimes(1);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('with normal delay', () => {
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo%2Fbar/move_trash');
+ req.flush(null);
+ expect(req.request.body).toEqual({ delay: 0 });
+ });
+
+ it('with delay < 0', () => {
+ const oldDate = moment().subtract(24, 'hour').toDate();
+ component.moveForm.patchValue({ expiresAt: oldDate });
+
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo%2Fbar/move_trash');
+ req.flush(null);
+ expect(req.request.body).toEqual({ delay: 0 });
+ });
+
+ it('with delay < 0', () => {
+ const oldDate = moment().add(24, 'hour').toISOString();
+ component.moveForm.patchValue({ expiresAt: oldDate });
+
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo%2Fbar/move_trash');
+ req.flush(null);
+ expect(req.request.body.delay).toBeGreaterThan(76390);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts
new file mode 100644
index 000000000..ccf381f9c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts
@@ -0,0 +1,94 @@
+import { Component, OnInit } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import moment from 'moment';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-move-modal',
+ templateUrl: './rbd-trash-move-modal.component.html',
+ styleUrls: ['./rbd-trash-move-modal.component.scss']
+})
+export class RbdTrashMoveModalComponent implements OnInit {
+ // initial state
+ poolName: string;
+ namespace: string;
+ imageName: string;
+ hasSnapshots: boolean;
+
+ imageSpec: ImageSpec;
+ imageSpecStr: string;
+ executingTasks: ExecutingTask[];
+
+ moveForm: CdFormGroup;
+ pattern: string;
+
+ constructor(
+ private rbdService: RbdService,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private fb: CdFormBuilder,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.moveForm = this.fb.group({
+ expiresAt: [
+ '',
+ [
+ CdValidators.custom('format', (expiresAt: string) => {
+ const result = expiresAt === '' || moment(expiresAt, 'YYYY-MM-DD HH:mm:ss').isValid();
+ return !result;
+ }),
+ CdValidators.custom('expired', (expiresAt: string) => {
+ const result = moment().isAfter(expiresAt);
+ return result;
+ })
+ ]
+ ]
+ });
+ }
+
+ ngOnInit() {
+ this.imageSpec = new ImageSpec(this.poolName, this.namespace, this.imageName);
+ this.imageSpecStr = this.imageSpec.toString();
+ this.pattern = `${this.poolName}/${this.imageName}`;
+ }
+
+ moveImage() {
+ let delay = 0;
+ const expiresAt = this.moveForm.getValue('expiresAt');
+
+ if (expiresAt) {
+ delay = moment(expiresAt, 'YYYY-MM-DD HH:mm:ss').diff(moment(), 'seconds', true);
+ }
+
+ if (delay < 0) {
+ delay = 0;
+ }
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/move', {
+ image_spec: this.imageSpecStr
+ }),
+ call: this.rbdService.moveTrash(this.imageSpec, delay)
+ })
+ .subscribe({
+ complete: () => {
+ this.activeModal.close();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html
new file mode 100644
index 000000000..7c761f8f4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html
@@ -0,0 +1,46 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n
+ class="modal-title">Purge Trash</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="purgeForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="purgeForm"
+ novalidate>
+ <div class="modal-body">
+ <p i18n>To purge, select&nbsp;
+ <kbd>All</kbd>&nbsp;
+ or one pool and click&nbsp;
+ <kbd>Purge</kbd>.&nbsp;</p>
+
+ <div class="form-group">
+ <label class="col-form-label mx-auto"
+ i18n>Pool:</label>
+ <input class="form-control"
+ type="text"
+ placeholder="Pool name..."
+ i18n-placeholder
+ formControlName="poolName"
+ *ngIf="!poolPermission.read">
+ <select id="poolName"
+ name="poolName"
+ class="form-control"
+ formControlName="poolName"
+ *ngIf="poolPermission.read">
+ <option value=""
+ i18n>All</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool">{{ pool }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="purge()"
+ [form]="purgeForm"
+ [submitText]="actionLabels.PURGE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts
new file mode 100644
index 000000000..7f1708fff
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts
@@ -0,0 +1,105 @@
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+ TestRequest
+} from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal.component';
+
+describe('RbdTrashPurgeModalComponent', () => {
+ let component: RbdTrashPurgeModalComponent;
+ let fixture: ComponentFixture<RbdTrashPurgeModalComponent>;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule
+ ],
+ declarations: [RbdTrashPurgeModalComponent],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashPurgeModalComponent);
+ httpTesting = TestBed.inject(HttpTestingController);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should finish ngOnInit', fakeAsync(() => {
+ component.poolPermission = new Permission(['read', 'create', 'update', 'delete']);
+ fixture.detectChanges();
+ const req = httpTesting.expectOne('api/pool?attrs=pool_name,application_metadata');
+ req.flush([
+ {
+ application_metadata: ['foo'],
+ pool_name: 'bar'
+ },
+ {
+ application_metadata: ['rbd'],
+ pool_name: 'baz'
+ }
+ ]);
+ tick();
+ expect(component.pools).toEqual(['baz']);
+ expect(component.purgeForm).toBeTruthy();
+ }));
+
+ it('should call ngOnInit without pool permissions', () => {
+ component.poolPermission = new Permission([]);
+ component.ngOnInit();
+ httpTesting.verify();
+ });
+
+ describe('should call purge', () => {
+ let notificationService: NotificationService;
+ let activeModal: NgbActiveModal;
+ let req: TestRequest;
+
+ beforeEach(() => {
+ fixture.detectChanges();
+ notificationService = TestBed.inject(NotificationService);
+ activeModal = TestBed.inject(NgbActiveModal);
+
+ component.purgeForm.patchValue({ poolName: 'foo' });
+
+ spyOn(activeModal, 'close').and.stub();
+ spyOn(component.purgeForm, 'setErrors').and.stub();
+ spyOn(notificationService, 'show').and.stub();
+
+ component.purge();
+
+ req = httpTesting.expectOne('api/block/image/trash/purge/?pool_name=foo');
+ });
+
+ it('with success', () => {
+ req.flush(null);
+ expect(component.purgeForm.setErrors).toHaveBeenCalledTimes(0);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('with failure', () => {
+ req.flush(null, { status: 500, statusText: 'failure' });
+ expect(component.purgeForm.setErrors).toHaveBeenCalledTimes(1);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(0);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts
new file mode 100644
index 000000000..e4df25d15
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts
@@ -0,0 +1,74 @@
+import { Component, OnInit } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { Pool } from '~/app/ceph/pool/pool';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-purge-modal',
+ templateUrl: './rbd-trash-purge-modal.component.html',
+ styleUrls: ['./rbd-trash-purge-modal.component.scss']
+})
+export class RbdTrashPurgeModalComponent implements OnInit {
+ poolPermission: Permission;
+ purgeForm: CdFormGroup;
+ pools: any[];
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdService: RbdService,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private fb: CdFormBuilder,
+ private poolService: PoolService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.poolPermission = this.authStorageService.getPermissions().pool;
+ }
+
+ createForm() {
+ this.purgeForm = this.fb.group({
+ poolName: ''
+ });
+ }
+
+ ngOnInit() {
+ if (this.poolPermission.read) {
+ this.poolService.list(['pool_name', 'application_metadata']).then((resp) => {
+ this.pools = resp
+ .filter((pool: Pool) => pool.application_metadata.includes('rbd'))
+ .map((pool: Pool) => pool.pool_name);
+ });
+ }
+
+ this.createForm();
+ }
+
+ purge() {
+ const poolName = this.purgeForm.getValue('poolName') || '';
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/purge', {
+ pool_name: poolName
+ }),
+ call: this.rbdService.purgeTrash(poolName)
+ })
+ .subscribe({
+ error: () => {
+ this.purgeForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html
new file mode 100644
index 000000000..2cc3e08df
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html
@@ -0,0 +1,41 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n
+ class="modal-title">Restore Image</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="restoreForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="restoreForm"
+ novalidate>
+ <div class="modal-body">
+ <p i18n>To restore&nbsp;
+ <kbd>{{ imageSpec }}@{{ imageId }}</kbd>,&nbsp;
+ type the image's new name and click&nbsp;
+ <kbd>Restore</kbd>.</p>
+
+ <div class="form-group">
+ <label class="col-form-label"
+ for="name"
+ i18n>New Name</label>
+ <input type="text"
+ class="form-control"
+ name="name"
+ id="name"
+ autocomplete="off"
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="restoreForm.showError('name', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="restore()"
+ [form]="restoreForm"
+ [submitText]="actionLabels.RESTORE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts
new file mode 100644
index 000000000..7eb963a6e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts
@@ -0,0 +1,81 @@
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+ TestRequest
+} 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal.component';
+
+describe('RbdTrashRestoreModalComponent', () => {
+ let component: RbdTrashRestoreModalComponent;
+ let fixture: ComponentFixture<RbdTrashRestoreModalComponent>;
+
+ configureTestBed({
+ declarations: [RbdTrashRestoreModalComponent],
+ imports: [
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ ToastrModule.forRoot(),
+ SharedModule,
+ RouterTestingModule
+ ],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashRestoreModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should call restore', () => {
+ let httpTesting: HttpTestingController;
+ let notificationService: NotificationService;
+ let activeModal: NgbActiveModal;
+ let req: TestRequest;
+
+ beforeEach(() => {
+ httpTesting = TestBed.inject(HttpTestingController);
+ notificationService = TestBed.inject(NotificationService);
+ activeModal = TestBed.inject(NgbActiveModal);
+
+ component.poolName = 'foo';
+ component.imageName = 'bar';
+ component.imageId = '113cb6963793';
+ component.ngOnInit();
+
+ spyOn(activeModal, 'close').and.stub();
+ spyOn(component.restoreForm, 'setErrors').and.stub();
+ spyOn(notificationService, 'show').and.stub();
+
+ component.restore();
+
+ req = httpTesting.expectOne('api/block/image/trash/foo%2F113cb6963793/restore');
+ });
+
+ it('with success', () => {
+ req.flush(null);
+ expect(component.restoreForm.setErrors).toHaveBeenCalledTimes(0);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('with failure', () => {
+ req.flush(null, { status: 500, statusText: 'failure' });
+ expect(component.restoreForm.setErrors).toHaveBeenCalledTimes(1);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(0);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts
new file mode 100644
index 000000000..860d66cc0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts
@@ -0,0 +1,65 @@
+import { Component, OnInit } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ImageSpec } from '~/app/shared/models/image-spec';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-restore-modal',
+ templateUrl: './rbd-trash-restore-modal.component.html',
+ styleUrls: ['./rbd-trash-restore-modal.component.scss']
+})
+export class RbdTrashRestoreModalComponent implements OnInit {
+ poolName: string;
+ namespace: string;
+ imageName: string;
+ imageSpec: string;
+ imageId: string;
+ executingTasks: ExecutingTask[];
+
+ restoreForm: CdFormGroup;
+
+ constructor(
+ private rbdService: RbdService,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private fb: CdFormBuilder,
+ private taskWrapper: TaskWrapperService
+ ) {}
+
+ ngOnInit() {
+ this.imageSpec = new ImageSpec(this.poolName, this.namespace, this.imageName).toString();
+ this.restoreForm = this.fb.group({
+ name: this.imageName
+ });
+ }
+
+ restore() {
+ const name = this.restoreForm.getValue('name');
+ const imageSpec = new ImageSpec(this.poolName, this.namespace, this.imageId);
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/restore', {
+ image_id_spec: imageSpec.toString(),
+ new_image_name: name
+ }),
+ call: this.rbdService.restoreTrash(imageSpec, name)
+ })
+ .subscribe({
+ error: () => {
+ this.restoreForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts
new file mode 100644
index 000000000..47772304b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts
@@ -0,0 +1,23 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { SharedModule } from '../shared/shared.module';
+import { CephfsModule } from './cephfs/cephfs.module';
+import { ClusterModule } from './cluster/cluster.module';
+import { DashboardModule } from './dashboard/dashboard.module';
+import { NfsModule } from './nfs/nfs.module';
+import { PerformanceCounterModule } from './performance-counter/performance-counter.module';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ ClusterModule,
+ DashboardModule,
+ PerformanceCounterModule,
+ CephfsModule,
+ NfsModule,
+ SharedModule
+ ],
+ declarations: []
+})
+export class CephModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.html
new file mode 100644
index 000000000..b81bc20ba
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.html
@@ -0,0 +1,12 @@
+<div class="chart-container">
+ <canvas baseChart
+ #chartCanvas
+ [datasets]="chart.datasets"
+ [options]="chart.options"
+ [chartType]="chart.chartType">
+ </canvas>
+ <div class="chartjs-tooltip"
+ #chartTooltip>
+ <table></table>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.scss
new file mode 100644
index 000000000..f90af6f5a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.scss
@@ -0,0 +1,8 @@
+@use './src/styles/chart-tooltip';
+
+.chart-container {
+ height: 500px;
+ margin-bottom: 20px;
+ position: relative;
+ width: 100%;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.spec.ts
new file mode 100644
index 000000000..4ba20fa89
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.spec.ts
@@ -0,0 +1,81 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ChartsModule } from 'ng2-charts';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CephfsChartComponent } from './cephfs-chart.component';
+
+describe('CephfsChartComponent', () => {
+ let component: CephfsChartComponent;
+ let fixture: ComponentFixture<CephfsChartComponent>;
+
+ const counter = [
+ [0, 15],
+ [5, 15],
+ [10, 25],
+ [15, 50]
+ ];
+
+ configureTestBed({
+ imports: [ChartsModule],
+ declarations: [CephfsChartComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsChartComponent);
+ component = fixture.componentInstance;
+ component.mdsCounter = {
+ 'mds_server.handle_client_request': counter,
+ 'mds_mem.ino': counter,
+ name: 'a'
+ };
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('completed the chart', () => {
+ const lhs = component.chart.datasets[0].data;
+ expect(lhs.length).toBe(3);
+ expect(lhs).toEqual([
+ {
+ x: 5000,
+ y: 15
+ },
+ {
+ x: 10000,
+ y: 25
+ },
+ {
+ x: 15000,
+ y: 50
+ }
+ ]);
+
+ const rhs = component.chart.datasets[1].data;
+ expect(rhs.length).toBe(3);
+ expect(rhs).toEqual([
+ {
+ x: 5000,
+ y: 0
+ },
+ {
+ x: 10000,
+ y: 10
+ },
+ {
+ x: 15000,
+ y: 25
+ }
+ ]);
+ });
+
+ it('should force angular to update the chart datasets array in order to update the graph', () => {
+ const oldDatasets = component.chart.datasets;
+ component.ngOnChanges();
+ expect(oldDatasets).toEqual(component.chart.datasets);
+ expect(oldDatasets).not.toBe(component.chart.datasets);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.ts
new file mode 100644
index 000000000..7f3c9437d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-chart/cephfs-chart.component.ts
@@ -0,0 +1,196 @@
+import { Component, ElementRef, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
+
+import { ChartDataSets, ChartOptions, ChartPoint, ChartType } from 'chart.js';
+import _ from 'lodash';
+import moment from 'moment';
+
+import { ChartTooltip } from '~/app/shared/models/chart-tooltip';
+
+@Component({
+ selector: 'cd-cephfs-chart',
+ templateUrl: './cephfs-chart.component.html',
+ styleUrls: ['./cephfs-chart.component.scss']
+})
+export class CephfsChartComponent implements OnChanges, OnInit {
+ @ViewChild('chartCanvas', { static: true })
+ chartCanvas: ElementRef;
+ @ViewChild('chartTooltip', { static: true })
+ chartTooltip: ElementRef;
+
+ @Input()
+ mdsCounter: any;
+
+ lhsCounter = 'mds_mem.ino';
+ rhsCounter = 'mds_server.handle_client_request';
+
+ chart: {
+ datasets: ChartDataSets[];
+ options: ChartOptions;
+ chartType: ChartType;
+ } = {
+ datasets: [
+ {
+ label: this.lhsCounter,
+ yAxisID: 'LHS',
+ data: [],
+ lineTension: 0.1
+ },
+ {
+ label: this.rhsCounter,
+ yAxisID: 'RHS',
+ data: [],
+ lineTension: 0.1
+ }
+ ],
+ options: {
+ title: {
+ text: '',
+ display: true
+ },
+ responsive: true,
+ maintainAspectRatio: false,
+ legend: {
+ position: 'top'
+ },
+ scales: {
+ xAxes: [
+ {
+ position: 'top',
+ type: 'time',
+ time: {
+ displayFormats: {
+ quarter: 'MMM YYYY'
+ }
+ },
+ ticks: {
+ maxRotation: 0
+ }
+ }
+ ],
+ yAxes: [
+ {
+ id: 'LHS',
+ type: 'linear',
+ position: 'left'
+ },
+ {
+ id: 'RHS',
+ type: 'linear',
+ position: 'right'
+ }
+ ]
+ },
+ tooltips: {
+ enabled: false,
+ mode: 'index',
+ intersect: false,
+ position: 'nearest',
+ callbacks: {
+ // Pick the Unix timestamp of the first tooltip item.
+ title: (tooltipItems, data): string => {
+ let ts = 0;
+ if (tooltipItems.length > 0) {
+ const item = tooltipItems[0];
+ const point = data.datasets[item.datasetIndex].data[item.index] as ChartPoint;
+ ts = point.x as number;
+ }
+ return ts.toString();
+ }
+ }
+ }
+ },
+ chartType: 'line'
+ };
+
+ ngOnInit() {
+ if (_.isUndefined(this.mdsCounter)) {
+ return;
+ }
+ this.setChartTooltip();
+ this.updateChart();
+ }
+
+ ngOnChanges() {
+ if (_.isUndefined(this.mdsCounter)) {
+ return;
+ }
+ this.updateChart();
+ }
+
+ private setChartTooltip() {
+ const chartTooltip = new ChartTooltip(
+ this.chartCanvas,
+ this.chartTooltip,
+ (tooltip: any) => tooltip.caretX + 'px',
+ (tooltip: any) => tooltip.caretY - tooltip.height - 23 + 'px'
+ );
+ chartTooltip.getTitle = (ts) => moment(ts, 'x').format('LTS');
+ chartTooltip.checkOffset = true;
+ const chartOptions: ChartOptions = {
+ title: {
+ text: this.mdsCounter.name
+ },
+ tooltips: {
+ custom: (tooltip) => chartTooltip.customTooltips(tooltip)
+ }
+ };
+ _.merge(this.chart, { options: chartOptions });
+ }
+
+ private updateChart() {
+ const chartDataSets: ChartDataSets[] = [
+ {
+ data: this.convertTimeSeries(this.mdsCounter[this.lhsCounter])
+ },
+ {
+ data: this.deltaTimeSeries(this.mdsCounter[this.rhsCounter])
+ }
+ ];
+ _.merge(this.chart, {
+ datasets: chartDataSets
+ });
+ this.chart.datasets = [...this.chart.datasets]; // Force angular to update
+ }
+
+ /**
+ * Convert ceph-mgr's time series format (list of 2-tuples
+ * with seconds-since-epoch timestamps) into what chart.js
+ * can handle (list of objects with millisecs-since-epoch
+ * timestamps)
+ */
+ private convertTimeSeries(sourceSeries: any) {
+ const data: any[] = [];
+ _.each(sourceSeries, (dp) => {
+ data.push({
+ x: dp[0] * 1000,
+ y: dp[1]
+ });
+ });
+
+ /**
+ * MDS performance counters chart is expecting the same number of items
+ * from each data series. Since in deltaTimeSeries we are ignoring the first
+ * element, we will do the same here.
+ */
+ data.shift();
+
+ return data;
+ }
+
+ private deltaTimeSeries(sourceSeries: any) {
+ let i;
+ let prev = sourceSeries[0];
+ const result = [];
+ for (i = 1; i < sourceSeries.length; i++) {
+ const cur = sourceSeries[i];
+
+ result.push({
+ x: cur[0] * 1000,
+ y: cur[1] - prev[1]
+ });
+
+ prev = cur;
+ }
+ return result;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.html
new file mode 100644
index 000000000..cb1ee364c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.html
@@ -0,0 +1,13 @@
+<cd-table [data]="clients.data"
+ [columns]="columns"
+ [status]="clients.status"
+ [autoReload]="-1"
+ (fetchData)="triggerApiUpdate.emit()"
+ selectionType="single"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.spec.ts
new file mode 100644
index 000000000..f7a7f64bf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.spec.ts
@@ -0,0 +1,83 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { CephfsClientsComponent } from './cephfs-clients.component';
+
+describe('CephfsClientsComponent', () => {
+ let component: CephfsClientsComponent;
+ let fixture: ComponentFixture<CephfsClientsComponent>;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ ToastrModule.forRoot(),
+ SharedModule,
+ HttpClientTestingModule
+ ],
+ declarations: [CephfsClientsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsClientsComponent);
+ component = fixture.componentInstance;
+ component.clients = {
+ status: new TableStatusViewCache(ViewCacheStatus.ValueOk),
+ data: [{}, {}, {}, {}]
+ };
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Evict'],
+ primary: { multiple: 'Evict', executing: 'Evict', single: 'Evict', no: 'Evict' }
+ },
+ 'create,update': {
+ actions: ['Evict'],
+ primary: { multiple: 'Evict', executing: 'Evict', single: 'Evict', no: 'Evict' }
+ },
+ 'create,delete': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ create: {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ 'update,delete': {
+ actions: ['Evict'],
+ primary: { multiple: 'Evict', executing: 'Evict', single: 'Evict', no: 'Evict' }
+ },
+ update: {
+ actions: ['Evict'],
+ primary: { multiple: 'Evict', executing: 'Evict', single: 'Evict', no: 'Evict' }
+ },
+ delete: {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.ts
new file mode 100644
index 000000000..fb43cca4b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-clients/cephfs-clients.component.ts
@@ -0,0 +1,102 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+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';
+
+@Component({
+ selector: 'cd-cephfs-clients',
+ templateUrl: './cephfs-clients.component.html',
+ styleUrls: ['./cephfs-clients.component.scss']
+})
+export class CephfsClientsComponent implements OnInit {
+ @Input()
+ id: number;
+
+ @Input()
+ clients: {
+ data: any[];
+ status: TableStatusViewCache;
+ };
+
+ @Output()
+ triggerApiUpdate = new EventEmitter();
+
+ columns: CdTableColumn[];
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ modalRef: NgbModalRef;
+
+ selection = new CdTableSelection();
+
+ constructor(
+ private cephfsService: CephfsService,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ private authStorageService: AuthStorageService,
+ private actionLabels: ActionLabelsI18n
+ ) {
+ this.permission = this.authStorageService.getPermissions().cephfs;
+ const evictAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.signOut,
+ click: () => this.evictClientModal(),
+ name: this.actionLabels.EVICT
+ };
+ this.tableActions = [evictAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ { prop: 'id', name: $localize`id` },
+ { prop: 'type', name: $localize`type` },
+ { prop: 'state', name: $localize`state` },
+ { prop: 'version', name: $localize`version` },
+ { prop: 'hostname', name: $localize`Host` },
+ { prop: 'root', name: $localize`root` }
+ ];
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ evictClient(clientId: number) {
+ this.cephfsService.evictClient(this.id, clientId).subscribe(
+ () => {
+ this.triggerApiUpdate.emit();
+ this.modalRef.close();
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Evicted client '${clientId}'`
+ );
+ },
+ () => {
+ this.modalRef.componentInstance.stopLoadingSpinner();
+ }
+ );
+ }
+
+ evictClientModal() {
+ const clientId = this.selection.first().id;
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'client',
+ itemNames: [clientId],
+ actionDescription: 'evict',
+ submitAction: () => this.evictClient(clientId)
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.html
new file mode 100644
index 000000000..c1d33d8e0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.html
@@ -0,0 +1,42 @@
+<div class="row">
+ <div class="col-sm-6">
+ <legend i18n>Ranks</legend>
+ <cd-table [data]="data.ranks"
+ [columns]="columns.ranks"
+ [toolHeader]="false">
+ </cd-table>
+
+ <legend i18n>Standbys</legend>
+ <cd-table-key-value [data]="standbys">
+ </cd-table-key-value>
+ </div>
+
+ <div class="col-sm-6">
+ <legend i18n>Pools</legend>
+ <cd-table [data]="data.pools"
+ [columns]="columns.pools"
+ [toolHeader]="false">
+ </cd-table>
+ </div>
+</div>
+
+<legend i18n>MDS performance counters</legend>
+<div class="row"
+ *ngFor="let mdsCounter of objectValues(data.mdsCounters); trackBy: trackByFn">
+ <div class="col-md-12">
+ <cd-cephfs-chart [mdsCounter]="mdsCounter"></cd-cephfs-chart>
+ </div>
+</div>
+
+<!-- templates -->
+<ng-template #poolUsageTpl
+ let-row="row">
+ <cd-usage-bar [total]="row.size"
+ [used]="row.used"></cd-usage-bar>
+</ng-template>
+
+<ng-template #activityTmpl
+ let-row="row"
+ let-value="value">
+ {{ row.state === 'standby-replay' ? 'Evts' : 'Reqs' }}: {{ value | dimless }} /s
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.scss
new file mode 100644
index 000000000..d2b859af0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.scss
@@ -0,0 +1,3 @@
+.progress {
+ margin-bottom: 0;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.spec.ts
new file mode 100644
index 000000000..b62fce9d9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.spec.ts
@@ -0,0 +1,55 @@
+import { Component, Input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CephfsDetailComponent } from './cephfs-detail.component';
+
+@Component({ selector: 'cd-cephfs-chart', template: '' })
+class CephfsChartStubComponent {
+ @Input()
+ mdsCounter: any;
+}
+
+describe('CephfsDetailComponent', () => {
+ let component: CephfsDetailComponent;
+ let fixture: ComponentFixture<CephfsDetailComponent>;
+
+ const updateDetails = (
+ standbys: string,
+ pools: any[],
+ ranks: any[],
+ mdsCounters: object,
+ name: string
+ ) => {
+ component.data = {
+ standbys,
+ pools,
+ ranks,
+ mdsCounters,
+ name
+ };
+ fixture.detectChanges();
+ };
+
+ configureTestBed({
+ imports: [SharedModule],
+ declarations: [CephfsDetailComponent, CephfsChartStubComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsDetailComponent);
+ component = fixture.componentInstance;
+ updateDetails('b', [], [], { a: { name: 'a', x: [0], y: [0, 1] } }, 'someFs');
+ fixture.detectChanges();
+ component.ngOnChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('prepares standby on change', () => {
+ expect(component.standbys).toEqual([{ key: 'Standby daemons', value: 'b' }]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.ts
new file mode 100644
index 000000000..87985a049
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-detail/cephfs-detail.component.ts
@@ -0,0 +1,91 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+
+@Component({
+ selector: 'cd-cephfs-detail',
+ templateUrl: './cephfs-detail.component.html',
+ styleUrls: ['./cephfs-detail.component.scss']
+})
+export class CephfsDetailComponent implements OnChanges, OnInit {
+ @ViewChild('poolUsageTpl', { static: true })
+ poolUsageTpl: TemplateRef<any>;
+ @ViewChild('activityTmpl', { static: true })
+ activityTmpl: TemplateRef<any>;
+
+ @Input()
+ data: {
+ standbys: string;
+ pools: any[];
+ ranks: any[];
+ mdsCounters: object;
+ name: string;
+ };
+
+ columns: {
+ ranks: CdTableColumn[];
+ pools: CdTableColumn[];
+ };
+ standbys: any[] = [];
+
+ objectValues = Object.values;
+
+ constructor(private dimlessBinary: DimlessBinaryPipe, private dimless: DimlessPipe) {}
+
+ ngOnChanges() {
+ this.setStandbys();
+ }
+
+ private setStandbys() {
+ this.standbys = [
+ {
+ key: $localize`Standby daemons`,
+ value: this.data.standbys
+ }
+ ];
+ }
+
+ ngOnInit() {
+ this.columns = {
+ ranks: [
+ { prop: 'rank', name: $localize`Rank` },
+ { prop: 'state', name: $localize`State` },
+ { prop: 'mds', name: $localize`Daemon` },
+ { prop: 'activity', name: $localize`Activity`, cellTemplate: this.activityTmpl },
+ { prop: 'dns', name: $localize`Dentries`, pipe: this.dimless },
+ { prop: 'inos', name: $localize`Inodes`, pipe: this.dimless },
+ { prop: 'dirs', name: $localize`Dirs`, pipe: this.dimless },
+ { prop: 'caps', name: $localize`Caps`, pipe: this.dimless }
+ ],
+ pools: [
+ { prop: 'pool', name: $localize`Pool` },
+ { prop: 'type', name: $localize`Type` },
+ { prop: 'size', name: $localize`Size`, pipe: this.dimlessBinary },
+ {
+ name: $localize`Usage`,
+ cellTemplate: this.poolUsageTpl,
+ comparator: (_valueA: any, _valueB: any, rowA: any, rowB: any) => {
+ const valA = rowA.used / rowA.avail;
+ const valB = rowB.used / rowB.avail;
+
+ if (valA === valB) {
+ return 0;
+ }
+
+ if (valA > valB) {
+ return 1;
+ } else {
+ return -1;
+ }
+ }
+ } as CdTableColumn
+ ]
+ };
+ }
+
+ trackByFn(_index: any, item: any) {
+ return item.name;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html
new file mode 100644
index 000000000..1d4b2ece8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html
@@ -0,0 +1,75 @@
+<div class="row">
+ <div class="col-sm-4 pr-0">
+ <div class="card">
+ <div class="card-header">
+ <button type="button"
+ [class.disabled]="loadingIndicator"
+ class="btn btn-light pull-right"
+ (click)="refreshAllDirectories()">
+ <i [ngClass]="[icons.large, icons.refresh]"
+ [class.fa-spin]="loadingIndicator"></i>
+ </button>
+ </div>
+ <div class="card-body">
+ <tree-root *ngIf="nodes"
+ [nodes]="nodes"
+ [options]="treeOptions">
+ <ng-template #loadingTemplate>
+ <i [ngClass]="[icons.spinner, icons.spin]"></i>
+ </ng-template>
+ </tree-root>
+ </div>
+ </div>
+ </div>
+ <!-- Selection details -->
+ <div class="col-sm-8 metadata"
+ *ngIf="selectedDir">
+ <div class="card">
+ <div class="card-header">
+ {{ selectedDir.path }}
+ </div>
+ <div class="card-body">
+ <ng-container *ngIf="selectedDir.path !== '/'">
+ <legend i18n>Quotas</legend>
+ <cd-table [data]="settings"
+ [columns]="quota.columns"
+ [limit]="0"
+ [footer]="false"
+ selectionType="single"
+ (updateSelection)="quota.updateSelection($event)"
+ [onlyActionHeader]="true"
+ identifier="quotaKey"
+ [forceIdentifier]="true"
+ [toolHeader]="false">
+ <cd-table-actions class="only-table-actions"
+ [permission]="permission"
+ [selection]="quota.selection"
+ [tableActions]="quota.tableActions">
+ </cd-table-actions>
+ </cd-table>
+ </ng-container>
+
+ <legend i18n>Snapshots</legend>
+ <cd-table [data]="selectedDir.snapshots"
+ [columns]="snapshot.columns"
+ identifier="name"
+ forceIdentifier="true"
+ selectionType="multiClick"
+ (updateSelection)="snapshot.updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="snapshot.selection"
+ [tableActions]="snapshot.tableActions">
+ </cd-table-actions>
+ </cd-table>
+ </div>
+ </div>
+ </div>
+</div>
+
+<ng-template #origin
+ let-row="row"
+ let-value="value">
+ <span class="quota-origin"
+ (click)="selectOrigin(value)">{{value}}</span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss
new file mode 100644
index 000000000..3334f0618
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss
@@ -0,0 +1,17 @@
+@use './src/styles/vendor/variables' as vv;
+
+// Angular2-Tree Component
+::ng-deep cd-cephfs-directories tree-root {
+ .tree-children {
+ overflow: inherit;
+ }
+}
+
+.quota-origin {
+ color: vv.$primary;
+ cursor: pointer;
+
+ &:hover {
+ color: vv.$gray-900;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts
new file mode 100644
index 000000000..3a43ac5c7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts
@@ -0,0 +1,1111 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { Type } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Validators } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { TreeComponent, TreeModule, TREE_ACTIONS } from '@circlon/angular-tree-component';
+import { NgbActiveModal, NgbModalModule, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { Observable, of } from 'rxjs';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+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 { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import {
+ CephfsDir,
+ CephfsQuotas,
+ CephfsSnapshot
+} from '~/app/shared/models/cephfs-directory-models';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, modalServiceShow, PermissionHelper } from '~/testing/unit-test-helper';
+import { CephfsDirectoriesComponent } from './cephfs-directories.component';
+
+describe('CephfsDirectoriesComponent', () => {
+ let component: CephfsDirectoriesComponent;
+ let fixture: ComponentFixture<CephfsDirectoriesComponent>;
+ let cephfsService: CephfsService;
+ let noAsyncUpdate: boolean;
+ let lsDirSpy: jasmine.Spy;
+ let modalShowSpy: jasmine.Spy;
+ let notificationShowSpy: jasmine.Spy;
+ let minValidator: jasmine.Spy;
+ let maxValidator: jasmine.Spy;
+ let minBinaryValidator: jasmine.Spy;
+ let maxBinaryValidator: jasmine.Spy;
+ let modal: NgbModalRef;
+
+ // Get's private attributes or functions
+ const get = {
+ nodeIds: (): { [path: string]: CephfsDir } => component['nodeIds'],
+ dirs: (): CephfsDir[] => component['dirs'],
+ requestedPaths: (): string[] => component['requestedPaths']
+ };
+
+ // Object contains mock data that will be reset before each test.
+ let mockData: {
+ nodes: any;
+ parent: any;
+ createdSnaps: CephfsSnapshot[] | any[];
+ deletedSnaps: CephfsSnapshot[] | any[];
+ updatedQuotas: { [path: string]: CephfsQuotas };
+ createdDirs: CephfsDir[];
+ };
+
+ // Object contains mock functions
+ const mockLib = {
+ quotas: (max_bytes: number, max_files: number): CephfsQuotas => ({ max_bytes, max_files }),
+ snapshots: (dirPath: string, howMany: number): CephfsSnapshot[] => {
+ const name = 'someSnapshot';
+ const snapshots = [];
+ const oneDay = 3600 * 24 * 1000;
+ for (let i = 0; i < howMany; i++) {
+ const snapName = `${name}${i + 1}`;
+ const path = `${dirPath}/.snap/${snapName}`;
+ const created = new Date(+new Date() - oneDay * i).toString();
+ snapshots.push({ name: snapName, path, created });
+ }
+ return snapshots;
+ },
+ dir: (parentPath: string, name: string, modifier: number): CephfsDir => {
+ const dirPath = `${parentPath === '/' ? '' : parentPath}/${name}`;
+ let snapshots = mockLib.snapshots(parentPath, modifier);
+ const extraSnapshots = mockData.createdSnaps.filter((s) => s.path === dirPath);
+ if (extraSnapshots.length > 0) {
+ snapshots = snapshots.concat(extraSnapshots);
+ }
+ const deletedSnapshots = mockData.deletedSnaps
+ .filter((s) => s.path === dirPath)
+ .map((s) => s.name);
+ if (deletedSnapshots.length > 0) {
+ snapshots = snapshots.filter((s) => !deletedSnapshots.includes(s.name));
+ }
+ return {
+ name,
+ path: dirPath,
+ parent: parentPath,
+ quotas: Object.assign(
+ mockLib.quotas(1024 * modifier, 10 * modifier),
+ mockData.updatedQuotas[dirPath] || {}
+ ),
+ snapshots: snapshots
+ };
+ },
+ // Only used inside other mocks
+ lsSingleDir: (path = ''): CephfsDir[] => {
+ const customDirs = mockData.createdDirs.filter((d) => d.parent === path);
+ const isCustomDir = mockData.createdDirs.some((d) => d.path === path);
+ if (isCustomDir || path.includes('b')) {
+ // 'b' has no sub directories
+ return customDirs;
+ }
+ return customDirs.concat([
+ // Directories are not sorted!
+ mockLib.dir(path, 'c', 3),
+ mockLib.dir(path, 'a', 1),
+ mockLib.dir(path, 'b', 2)
+ ]);
+ },
+ lsDir: (_id: number, path = ''): Observable<CephfsDir[]> => {
+ // will return 2 levels deep
+ let data = mockLib.lsSingleDir(path);
+ const paths = data.map((dir) => dir.path);
+ paths.forEach((pathL2) => {
+ data = data.concat(mockLib.lsSingleDir(pathL2));
+ });
+ if (path === '' || path === '/') {
+ // Adds root directory on ls of '/' to the directories list.
+ const root = mockLib.dir(path, '/', 1);
+ root.path = '/';
+ root.parent = undefined;
+ root.quotas = undefined;
+ data = [root].concat(data);
+ }
+ return of(data);
+ },
+ mkSnapshot: (_id: any, path: string, name: string): Observable<string> => {
+ mockData.createdSnaps.push({
+ name,
+ path,
+ created: new Date().toString()
+ });
+ return of(name);
+ },
+ rmSnapshot: (_id: any, path: string, name: string): Observable<string> => {
+ mockData.deletedSnaps.push({
+ name,
+ path,
+ created: new Date().toString()
+ });
+ return of(name);
+ },
+ updateQuota: (_id: any, path: string, updated: CephfsQuotas): Observable<string> => {
+ mockData.updatedQuotas[path] = Object.assign(mockData.updatedQuotas[path] || {}, updated);
+ return of('Response');
+ },
+ modalShow: (comp: Type<any>, init: any): any => {
+ modal = modalServiceShow(comp, init);
+ return modal;
+ },
+ getNodeById: (path: string) => {
+ return mockLib.useNode(path);
+ },
+ updateNodes: (path: string) => {
+ const p: Promise<any[]> = component.treeOptions.getChildren({ id: path });
+ return noAsyncUpdate ? () => p : mockLib.asyncNodeUpdate(p);
+ },
+ asyncNodeUpdate: fakeAsync((p: Promise<any[]>) => {
+ p.then((nodes) => {
+ mockData.nodes = mockData.nodes.concat(nodes);
+ });
+ tick();
+ }),
+ changeId: (id: number) => {
+ // For some reason this spy has to be renewed after usage
+ spyOn(global, 'setTimeout').and.callFake((fn) => fn());
+ component.id = id;
+ component.ngOnChanges();
+ mockData.nodes = component.nodes.concat(mockData.nodes);
+ },
+ selectNode: (path: string) => {
+ component.treeOptions.actionMapping.mouse.click(undefined, mockLib.useNode(path), undefined);
+ },
+ // Creates TreeNode with parents until root
+ useNode: (path: string): { id: string; parent: any; data: any; loadNodeChildren: Function } => {
+ const parentPath = path.split('/');
+ parentPath.pop();
+ const parentIsRoot = parentPath.length === 1;
+ const parent = parentIsRoot ? { id: '/' } : mockLib.useNode(parentPath.join('/'));
+ return {
+ id: path,
+ parent,
+ data: {},
+ loadNodeChildren: () => mockLib.updateNodes(path)
+ };
+ },
+ treeActions: {
+ toggleActive: (_a: any, node: any, _b: any) => {
+ return mockLib.updateNodes(node.id);
+ }
+ },
+ mkDir: (path: string, name: string, maxFiles: number, maxBytes: number) => {
+ const dir = mockLib.dir(path, name, 3);
+ dir.quotas.max_bytes = maxBytes * 1024;
+ dir.quotas.max_files = maxFiles;
+ mockData.createdDirs.push(dir);
+ // Below is needed for quota tests only where 4 dirs are mocked
+ get.nodeIds()[dir.path] = dir;
+ mockData.nodes.push({ id: dir.path });
+ },
+ createSnapshotThroughModal: (name: string) => {
+ component.createSnapshot();
+ modal.componentInstance.onSubmitForm({ name });
+ },
+ deleteSnapshotsThroughModal: (snapshots: CephfsSnapshot[]) => {
+ component.snapshot.selection.selected = snapshots;
+ component.deleteSnapshotModal();
+ modal.componentInstance.callSubmitAction();
+ },
+ updateQuotaThroughModal: (attribute: string, value: number) => {
+ component.quota.selection.selected = component.settings.filter(
+ (q) => q.quotaKey === attribute
+ );
+ component.updateQuotaModal();
+ modal.componentInstance.onSubmitForm({ [attribute]: value });
+ },
+ unsetQuotaThroughModal: (attribute: string) => {
+ component.quota.selection.selected = component.settings.filter(
+ (q) => q.quotaKey === attribute
+ );
+ component.unsetQuotaModal();
+ modal.componentInstance.onSubmit();
+ },
+ setFourQuotaDirs: (quotas: number[][]) => {
+ expect(quotas.length).toBe(4); // Make sure this function is used correctly
+ let path = '';
+ quotas.forEach((quota, index) => {
+ index += 1;
+ mockLib.mkDir(path === '' ? '/' : path, index.toString(), quota[0], quota[1]);
+ path += '/' + index;
+ });
+ mockData.parent = {
+ value: '3',
+ id: '/1/2/3',
+ parent: {
+ value: '2',
+ id: '/1/2',
+ parent: {
+ value: '1',
+ id: '/1',
+ parent: { value: '/', id: '/' }
+ }
+ }
+ };
+ mockLib.selectNode('/1/2/3/4');
+ }
+ };
+
+ // Expects that are used frequently
+ const assert = {
+ dirLength: (n: number) => expect(get.dirs().length).toBe(n),
+ nodeLength: (n: number) => expect(mockData.nodes.length).toBe(n),
+ lsDirCalledTimes: (n: number) => expect(lsDirSpy).toHaveBeenCalledTimes(n),
+ lsDirHasBeenCalledWith: (id: number, paths: string[]) => {
+ paths.forEach((path) => expect(lsDirSpy).toHaveBeenCalledWith(id, path));
+ assert.lsDirCalledTimes(paths.length);
+ },
+ requestedPaths: (expected: string[]) => expect(get.requestedPaths()).toEqual(expected),
+ snapshotsByName: (snaps: string[]) =>
+ expect(component.selectedDir.snapshots.map((s) => s.name)).toEqual(snaps),
+ dirQuotas: (bytes: number, files: number) => {
+ expect(component.selectedDir.quotas).toEqual({ max_bytes: bytes, max_files: files });
+ },
+ noQuota: (key: 'bytes' | 'files') => {
+ assert.quotaRow(key, '', 0, '');
+ },
+ quotaIsNotInherited: (key: 'bytes' | 'files', shownValue: any, nextMaximum: number) => {
+ const dir = component.selectedDir;
+ const path = dir.path;
+ assert.quotaRow(key, shownValue, nextMaximum, path);
+ },
+ quotaIsInherited: (key: 'bytes' | 'files', shownValue: any, path: string) => {
+ const isBytes = key === 'bytes';
+ const nextMaximum = get.nodeIds()[path].quotas[isBytes ? 'max_bytes' : 'max_files'];
+ assert.quotaRow(key, shownValue, nextMaximum, path);
+ },
+ quotaRow: (
+ key: 'bytes' | 'files',
+ shownValue: number | string,
+ nextTreeMaximum: number,
+ originPath: string
+ ) => {
+ const isBytes = key === 'bytes';
+ expect(component.settings[isBytes ? 1 : 0]).toEqual({
+ row: {
+ name: `Max ${isBytes ? 'size' : key}`,
+ value: shownValue,
+ originPath
+ },
+ quotaKey: `max_${key}`,
+ dirValue: expect.any(Number),
+ nextTreeMaximum: {
+ value: nextTreeMaximum,
+ path: expect.any(String)
+ }
+ });
+ },
+ quotaUnsetModalTexts: (titleText: string, message: string, notificationMsg: string) => {
+ expect(modalShowSpy).toHaveBeenCalledWith(
+ ConfirmationModalComponent,
+ expect.objectContaining({
+ titleText,
+ description: message,
+ buttonText: 'Unset'
+ })
+ );
+ expect(notificationShowSpy).toHaveBeenCalledWith(NotificationType.success, notificationMsg);
+ },
+ quotaUpdateModalTexts: (titleText: string, message: string, notificationMsg: string) => {
+ expect(modalShowSpy).toHaveBeenCalledWith(
+ FormModalComponent,
+ expect.objectContaining({
+ titleText,
+ message,
+ submitButtonText: 'Save'
+ })
+ );
+ expect(notificationShowSpy).toHaveBeenCalledWith(NotificationType.success, notificationMsg);
+ },
+ quotaUpdateModalField: (
+ type: string,
+ label: string,
+ key: string,
+ value: number,
+ max: number,
+ errors?: { [key: string]: string }
+ ) => {
+ expect(modalShowSpy).toHaveBeenCalledWith(
+ FormModalComponent,
+ expect.objectContaining({
+ fields: [
+ {
+ type,
+ label,
+ errors,
+ name: key,
+ value,
+ validators: expect.anything(),
+ required: true
+ }
+ ]
+ })
+ );
+ if (type === 'binary') {
+ expect(minBinaryValidator).toHaveBeenCalledWith(0);
+ expect(maxBinaryValidator).toHaveBeenCalledWith(max);
+ } else {
+ expect(minValidator).toHaveBeenCalledWith(0);
+ expect(maxValidator).toHaveBeenCalledWith(max);
+ }
+ }
+ };
+
+ configureTestBed(
+ {
+ imports: [
+ HttpClientTestingModule,
+ SharedModule,
+ RouterTestingModule,
+ TreeModule,
+ ToastrModule.forRoot(),
+ NgbModalModule
+ ],
+ declarations: [CephfsDirectoriesComponent],
+ providers: [NgbActiveModal]
+ },
+ [CriticalConfirmationModalComponent, FormModalComponent, ConfirmationModalComponent]
+ );
+
+ beforeEach(() => {
+ noAsyncUpdate = false;
+ mockData = {
+ nodes: [],
+ parent: undefined,
+ createdSnaps: [],
+ deletedSnaps: [],
+ createdDirs: [],
+ updatedQuotas: {}
+ };
+
+ cephfsService = TestBed.inject(CephfsService);
+ lsDirSpy = spyOn(cephfsService, 'lsDir').and.callFake(mockLib.lsDir);
+ spyOn(cephfsService, 'mkSnapshot').and.callFake(mockLib.mkSnapshot);
+ spyOn(cephfsService, 'rmSnapshot').and.callFake(mockLib.rmSnapshot);
+ spyOn(cephfsService, 'quota').and.callFake(mockLib.updateQuota);
+
+ modalShowSpy = spyOn(TestBed.inject(ModalService), 'show').and.callFake(mockLib.modalShow);
+ notificationShowSpy = spyOn(TestBed.inject(NotificationService), 'show').and.stub();
+
+ fixture = TestBed.createComponent(CephfsDirectoriesComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ spyOn(TREE_ACTIONS, 'TOGGLE_ACTIVE').and.callFake(mockLib.treeActions.toggleActive);
+
+ component.treeComponent = {
+ sizeChanged: () => null,
+ treeModel: { getNodeById: mockLib.getNodeById, update: () => null }
+ } as TreeComponent;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('mock self test', () => {
+ it('tests snapshots mock', () => {
+ expect(mockLib.snapshots('/a', 1).map((s) => ({ name: s.name, path: s.path }))).toEqual([
+ {
+ name: 'someSnapshot1',
+ path: '/a/.snap/someSnapshot1'
+ }
+ ]);
+ expect(mockLib.snapshots('/a/b', 3).map((s) => ({ name: s.name, path: s.path }))).toEqual([
+ {
+ name: 'someSnapshot1',
+ path: '/a/b/.snap/someSnapshot1'
+ },
+ {
+ name: 'someSnapshot2',
+ path: '/a/b/.snap/someSnapshot2'
+ },
+ {
+ name: 'someSnapshot3',
+ path: '/a/b/.snap/someSnapshot3'
+ }
+ ]);
+ });
+
+ it('tests dir mock', () => {
+ const path = '/a/b/c';
+ mockData.createdSnaps = [
+ { path, name: 's1' },
+ { path, name: 's2' }
+ ];
+ mockData.deletedSnaps = [
+ { path, name: 'someSnapshot2' },
+ { path, name: 's2' }
+ ];
+ const dir = mockLib.dir('/a/b', 'c', 2);
+ expect(dir.path).toBe('/a/b/c');
+ expect(dir.parent).toBe('/a/b');
+ expect(dir.quotas).toEqual({ max_bytes: 2048, max_files: 20 });
+ expect(dir.snapshots.map((s) => s.name)).toEqual(['someSnapshot1', 's1']);
+ });
+
+ it('tests lsdir mock', () => {
+ let dirs: CephfsDir[] = [];
+ mockLib.lsDir(2, '/a').subscribe((x) => (dirs = x));
+ expect(dirs.map((d) => d.path)).toEqual([
+ '/a/c',
+ '/a/a',
+ '/a/b',
+ '/a/c/c',
+ '/a/c/a',
+ '/a/c/b',
+ '/a/a/c',
+ '/a/a/a',
+ '/a/a/b'
+ ]);
+ });
+
+ describe('test quota update mock', () => {
+ const PATH = '/a';
+ const ID = 2;
+
+ const updateQuota = (quotas: CephfsQuotas) => mockLib.updateQuota(ID, PATH, quotas);
+
+ const expectMockUpdate = (max_bytes?: number, max_files?: number) =>
+ expect(mockData.updatedQuotas[PATH]).toEqual({
+ max_bytes,
+ max_files
+ });
+
+ const expectLsUpdate = (max_bytes?: number, max_files?: number) => {
+ let dir: CephfsDir;
+ mockLib.lsDir(ID, '/').subscribe((dirs) => (dir = dirs.find((d) => d.path === PATH)));
+ expect(dir.quotas).toEqual({
+ max_bytes,
+ max_files
+ });
+ };
+
+ it('tests to set quotas', () => {
+ expectLsUpdate(1024, 10);
+
+ updateQuota({ max_bytes: 512 });
+ expectMockUpdate(512);
+ expectLsUpdate(512, 10);
+
+ updateQuota({ max_files: 100 });
+ expectMockUpdate(512, 100);
+ expectLsUpdate(512, 100);
+ });
+
+ it('tests to unset quotas', () => {
+ updateQuota({ max_files: 0 });
+ expectMockUpdate(undefined, 0);
+ expectLsUpdate(1024, 0);
+
+ updateQuota({ max_bytes: 0 });
+ expectMockUpdate(0, 0);
+ expectLsUpdate(0, 0);
+ });
+ });
+ });
+
+ it('calls lsDir only if an id exits', () => {
+ assert.lsDirCalledTimes(0);
+
+ mockLib.changeId(1);
+ assert.lsDirCalledTimes(1);
+ expect(lsDirSpy).toHaveBeenCalledWith(1, '/');
+
+ mockLib.changeId(2);
+ assert.lsDirCalledTimes(2);
+ expect(lsDirSpy).toHaveBeenCalledWith(2, '/');
+ });
+
+ describe('listing sub directories', () => {
+ beforeEach(() => {
+ mockLib.changeId(1);
+ /**
+ * Tree looks like this:
+ * v /
+ * > a
+ * * b
+ * > c
+ * */
+ });
+
+ it('expands first level', () => {
+ // Tree will only show '*' if nor 'loadChildren' or 'children' are defined
+ expect(
+ mockData.nodes.map((node: any) => ({
+ [node.id]: node.hasChildren || node.isExpanded || Boolean(node.children)
+ }))
+ ).toEqual([{ '/': true }, { '/a': true }, { '/b': false }, { '/c': true }]);
+ });
+
+ it('resets all dynamic content on id change', () => {
+ mockLib.selectNode('/a');
+ /**
+ * Tree looks like this:
+ * v /
+ * v a <- Selected
+ * > a
+ * * b
+ * > c
+ * * b
+ * > c
+ * */
+ assert.requestedPaths(['/', '/a']);
+ assert.nodeLength(7);
+ assert.dirLength(16);
+ expect(component.selectedDir).toBeDefined();
+
+ mockLib.changeId(undefined);
+ assert.dirLength(0);
+ assert.requestedPaths([]);
+ expect(component.selectedDir).not.toBeDefined();
+ });
+
+ it('should select a node and show the directory contents', () => {
+ mockLib.selectNode('/a');
+ const dir = get.dirs().find((d) => d.path === '/a');
+ expect(component.selectedDir).toEqual(dir);
+ assert.quotaIsNotInherited('files', 10, 0);
+ assert.quotaIsNotInherited('bytes', '1 KiB', 0);
+ });
+
+ it('should extend the list by subdirectories when expanding', () => {
+ mockLib.selectNode('/a');
+ mockLib.selectNode('/a/c');
+ /**
+ * Tree looks like this:
+ * v /
+ * v a
+ * > a
+ * * b
+ * v c <- Selected
+ * > a
+ * * b
+ * > c
+ * * b
+ * > c
+ * */
+ assert.lsDirCalledTimes(3);
+ assert.requestedPaths(['/', '/a', '/a/c']);
+ assert.dirLength(22);
+ assert.nodeLength(10);
+ });
+
+ it('should update the tree after each selection', () => {
+ const spy = spyOn(component.treeComponent, 'sizeChanged').and.callThrough();
+ expect(spy).toHaveBeenCalledTimes(0);
+ mockLib.selectNode('/a');
+ expect(spy).toHaveBeenCalledTimes(1);
+ mockLib.selectNode('/a/c');
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should select parent by path', () => {
+ mockLib.selectNode('/a');
+ mockLib.selectNode('/a/c');
+ mockLib.selectNode('/a/c/a');
+ component.selectOrigin('/a');
+ expect(component.selectedDir.path).toBe('/a');
+ });
+
+ it('should refresh directories with no sub directories as they could have some now', () => {
+ mockLib.selectNode('/b');
+ /**
+ * Tree looks like this:
+ * v /
+ * > a
+ * * b <- Selected
+ * > c
+ * */
+ assert.lsDirCalledTimes(2);
+ assert.requestedPaths(['/', '/b']);
+ assert.nodeLength(4);
+ });
+
+ describe('used quotas', () => {
+ it('should use no quota if none is set', () => {
+ mockLib.setFourQuotaDirs([
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ]);
+ assert.noQuota('files');
+ assert.noQuota('bytes');
+ assert.dirQuotas(0, 0);
+ });
+
+ it('should use quota from upper parents', () => {
+ mockLib.setFourQuotaDirs([
+ [100, 0],
+ [0, 8],
+ [0, 0],
+ [0, 0]
+ ]);
+ assert.quotaIsInherited('files', 100, '/1');
+ assert.quotaIsInherited('bytes', '8 KiB', '/1/2');
+ assert.dirQuotas(0, 0);
+ });
+
+ it('should use quota from the parent with the lowest value (deep inheritance)', () => {
+ mockLib.setFourQuotaDirs([
+ [200, 1],
+ [100, 4],
+ [400, 3],
+ [300, 2]
+ ]);
+ assert.quotaIsInherited('files', 100, '/1/2');
+ assert.quotaIsInherited('bytes', '1 KiB', '/1');
+ assert.dirQuotas(2048, 300);
+ });
+
+ it('should use current value', () => {
+ mockLib.setFourQuotaDirs([
+ [200, 2],
+ [300, 4],
+ [400, 3],
+ [100, 1]
+ ]);
+ assert.quotaIsNotInherited('files', 100, 200);
+ assert.quotaIsNotInherited('bytes', '1 KiB', 2048);
+ assert.dirQuotas(1024, 100);
+ });
+ });
+ });
+
+ describe('snapshots', () => {
+ beforeEach(() => {
+ mockLib.changeId(1);
+ mockLib.selectNode('/a');
+ });
+
+ it('should create a snapshot', () => {
+ mockLib.createSnapshotThroughModal('newSnap');
+ expect(cephfsService.mkSnapshot).toHaveBeenCalledWith(1, '/a', 'newSnap');
+ assert.snapshotsByName(['someSnapshot1', 'newSnap']);
+ });
+
+ it('should delete a snapshot', () => {
+ mockLib.createSnapshotThroughModal('deleteMe');
+ mockLib.deleteSnapshotsThroughModal([component.selectedDir.snapshots[1]]);
+ assert.snapshotsByName(['someSnapshot1']);
+ });
+
+ it('should delete all snapshots', () => {
+ mockLib.createSnapshotThroughModal('deleteAll');
+ mockLib.deleteSnapshotsThroughModal(component.selectedDir.snapshots);
+ assert.snapshotsByName([]);
+ });
+ });
+
+ it('should test all snapshot table actions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions = permissionHelper.setPermissionsAndGetActions(
+ component.snapshot.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Create' }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ update: {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ describe('quotas', () => {
+ beforeEach(() => {
+ // Spies
+ minValidator = spyOn(Validators, 'min').and.callThrough();
+ maxValidator = spyOn(Validators, 'max').and.callThrough();
+ minBinaryValidator = spyOn(CdValidators, 'binaryMin').and.callThrough();
+ maxBinaryValidator = spyOn(CdValidators, 'binaryMax').and.callThrough();
+ // Select /a/c/b
+ mockLib.changeId(1);
+ mockLib.selectNode('/a');
+ mockLib.selectNode('/a/c');
+ mockLib.selectNode('/a/c/b');
+ // Quotas after selection
+ assert.quotaIsInherited('files', 10, '/a');
+ assert.quotaIsInherited('bytes', '1 KiB', '/a');
+ assert.dirQuotas(2048, 20);
+ });
+
+ describe('update modal', () => {
+ describe('max_files', () => {
+ beforeEach(() => {
+ mockLib.updateQuotaThroughModal('max_files', 5);
+ });
+
+ it('should update max_files correctly', () => {
+ expect(cephfsService.quota).toHaveBeenCalledWith(1, '/a/c/b', { max_files: 5 });
+ assert.quotaIsNotInherited('files', 5, 10);
+ });
+
+ it('uses the correct form field', () => {
+ assert.quotaUpdateModalField('number', 'Max files', 'max_files', 20, 10, {
+ min: 'Value has to be at least 0 or more',
+ max: 'Value has to be at most 10 or less'
+ });
+ });
+
+ it('shows the right texts', () => {
+ assert.quotaUpdateModalTexts(
+ `Update CephFS files quota for '/a/c/b'`,
+ `The inherited files quota 10 from '/a' is the maximum value to be used.`,
+ `Updated CephFS files quota for '/a/c/b'`
+ );
+ });
+ });
+
+ describe('max_bytes', () => {
+ beforeEach(() => {
+ mockLib.updateQuotaThroughModal('max_bytes', 512);
+ });
+
+ it('should update max_files correctly', () => {
+ expect(cephfsService.quota).toHaveBeenCalledWith(1, '/a/c/b', { max_bytes: 512 });
+ assert.quotaIsNotInherited('bytes', '512 B', 1024);
+ });
+
+ it('uses the correct form field', () => {
+ mockLib.updateQuotaThroughModal('max_bytes', 512);
+ assert.quotaUpdateModalField('binary', 'Max size', 'max_bytes', 2048, 1024);
+ });
+
+ it('shows the right texts', () => {
+ assert.quotaUpdateModalTexts(
+ `Update CephFS size quota for '/a/c/b'`,
+ `The inherited size quota 1 KiB from '/a' is the maximum value to be used.`,
+ `Updated CephFS size quota for '/a/c/b'`
+ );
+ });
+ });
+
+ describe('action behaviour', () => {
+ it('opens with next maximum as maximum if directory holds the current maximum', () => {
+ mockLib.updateQuotaThroughModal('max_bytes', 512);
+ mockLib.updateQuotaThroughModal('max_bytes', 888);
+ assert.quotaUpdateModalField('binary', 'Max size', 'max_bytes', 512, 1024);
+ });
+
+ it(`uses 'Set' action instead of 'Update' if the quota is not set (0)`, () => {
+ mockLib.updateQuotaThroughModal('max_bytes', 0);
+ mockLib.updateQuotaThroughModal('max_bytes', 200);
+ assert.quotaUpdateModalTexts(
+ `Set CephFS size quota for '/a/c/b'`,
+ `The inherited size quota 1 KiB from '/a' is the maximum value to be used.`,
+ `Set CephFS size quota for '/a/c/b'`
+ );
+ });
+ });
+ });
+
+ describe('unset modal', () => {
+ describe('max_files', () => {
+ beforeEach(() => {
+ mockLib.updateQuotaThroughModal('max_files', 5); // Sets usable quota
+ mockLib.unsetQuotaThroughModal('max_files');
+ });
+
+ it('should unset max_files correctly', () => {
+ expect(cephfsService.quota).toHaveBeenCalledWith(1, '/a/c/b', { max_files: 0 });
+ assert.dirQuotas(2048, 0);
+ });
+
+ it('shows the right texts', () => {
+ assert.quotaUnsetModalTexts(
+ `Unset CephFS files quota for '/a/c/b'`,
+ `Unset files quota 5 from '/a/c/b' in order to inherit files quota 10 from '/a'.`,
+ `Unset CephFS files quota for '/a/c/b'`
+ );
+ });
+ });
+
+ describe('max_bytes', () => {
+ beforeEach(() => {
+ mockLib.updateQuotaThroughModal('max_bytes', 512); // Sets usable quota
+ mockLib.unsetQuotaThroughModal('max_bytes');
+ });
+
+ it('should unset max_files correctly', () => {
+ expect(cephfsService.quota).toHaveBeenCalledWith(1, '/a/c/b', { max_bytes: 0 });
+ assert.dirQuotas(0, 20);
+ });
+
+ it('shows the right texts', () => {
+ assert.quotaUnsetModalTexts(
+ `Unset CephFS size quota for '/a/c/b'`,
+ `Unset size quota 512 B from '/a/c/b' in order to inherit size quota 1 KiB from '/a'.`,
+ `Unset CephFS size quota for '/a/c/b'`
+ );
+ });
+ });
+
+ describe('action behaviour', () => {
+ it('uses different Text if no quota is inherited', () => {
+ mockLib.selectNode('/a');
+ mockLib.unsetQuotaThroughModal('max_bytes');
+ assert.quotaUnsetModalTexts(
+ `Unset CephFS size quota for '/a'`,
+ `Unset size quota 1 KiB from '/a' in order to have no quota on the directory.`,
+ `Unset CephFS size quota for '/a'`
+ );
+ });
+
+ it('uses different Text if quota is already inherited', () => {
+ mockLib.unsetQuotaThroughModal('max_bytes');
+ assert.quotaUnsetModalTexts(
+ `Unset CephFS size quota for '/a/c/b'`,
+ `Unset size quota 2 KiB from '/a/c/b' which isn't used because of the inheritance ` +
+ `of size quota 1 KiB from '/a'.`,
+ `Unset CephFS size quota for '/a/c/b'`
+ );
+ });
+ });
+ });
+ });
+
+ describe('table actions', () => {
+ let actions: CdTableAction[];
+
+ const empty = (): CdTableSelection => new CdTableSelection();
+
+ const select = (value: number): CdTableSelection => {
+ const selection = new CdTableSelection();
+ selection.selected = [{ dirValue: value }];
+ return selection;
+ };
+
+ beforeEach(() => {
+ actions = component.quota.tableActions;
+ });
+
+ it(`shows 'Set' for empty and not set quotas`, () => {
+ const isSetVisible = actions[0].visible;
+ expect(isSetVisible(empty())).toBe(true);
+ expect(isSetVisible(select(0))).toBe(true);
+ expect(isSetVisible(select(1))).toBe(false);
+ });
+
+ it(`shows 'Update' for set quotas only`, () => {
+ const isUpdateVisible = actions[1].visible;
+ expect(isUpdateVisible(empty())).toBeFalsy();
+ expect(isUpdateVisible(select(0))).toBe(false);
+ expect(isUpdateVisible(select(1))).toBe(true);
+ });
+
+ it(`only enables 'Unset' for set quotas only`, () => {
+ const isUnsetDisabled = actions[2].disable;
+ expect(isUnsetDisabled(empty())).toBe(true);
+ expect(isUnsetDisabled(select(0))).toBe(true);
+ expect(isUnsetDisabled(select(1))).toBe(false);
+ });
+
+ it('should test all quota table actions permission combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission, {
+ single: { dirValue: 0 },
+ multiple: [{ dirValue: 0 }, {}]
+ });
+ const tableActions = permissionHelper.setPermissionsAndGetActions(
+ component.quota.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Set', 'Update', 'Unset'],
+ primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+ },
+ 'create,update': {
+ actions: ['Set', 'Update', 'Unset'],
+ primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+ },
+ 'create,delete': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ create: {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ 'update,delete': {
+ actions: ['Set', 'Update', 'Unset'],
+ primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+ },
+ update: {
+ actions: ['Set', 'Update', 'Unset'],
+ primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+ },
+ delete: {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+ });
+
+ describe('reload all', () => {
+ const calledPaths = ['/', '/a', '/a/c', '/a/c/a', '/a/c/a/b'];
+
+ const dirsByPath = (): string[] => get.dirs().map((d) => d.path);
+
+ beforeEach(() => {
+ mockLib.changeId(1);
+ mockLib.selectNode('/a');
+ mockLib.selectNode('/a/c');
+ mockLib.selectNode('/a/c/a');
+ mockLib.selectNode('/a/c/a/b');
+ });
+
+ it('should reload all requested paths', () => {
+ assert.lsDirHasBeenCalledWith(1, calledPaths);
+ lsDirSpy.calls.reset();
+ assert.lsDirHasBeenCalledWith(1, []);
+ component.refreshAllDirectories();
+ assert.lsDirHasBeenCalledWith(1, calledPaths);
+ });
+
+ it('should reload all requested paths if not selected anything', () => {
+ lsDirSpy.calls.reset();
+ mockLib.changeId(2);
+ assert.lsDirHasBeenCalledWith(2, ['/']);
+ lsDirSpy.calls.reset();
+ component.refreshAllDirectories();
+ assert.lsDirHasBeenCalledWith(2, ['/']);
+ });
+
+ it('should add new directories', () => {
+ // Create two new directories in preparation
+ const dirsBeforeRefresh = dirsByPath();
+ expect(dirsBeforeRefresh.includes('/a/c/has_dir_now')).toBe(false);
+ mockLib.mkDir('/a/c', 'has_dir_now', 0, 0);
+ mockLib.mkDir('/a/c/a/b', 'has_dir_now_too', 0, 0);
+ // Now the new directories will be fetched
+ component.refreshAllDirectories();
+ const dirsAfterRefresh = dirsByPath();
+ expect(dirsAfterRefresh.length - dirsBeforeRefresh.length).toBe(2);
+ expect(dirsAfterRefresh.includes('/a/c/has_dir_now')).toBe(true);
+ expect(dirsAfterRefresh.includes('/a/c/a/b/has_dir_now_too')).toBe(true);
+ });
+
+ it('should remove deleted directories', () => {
+ // Create one new directory and refresh in order to have it added to the directories list
+ mockLib.mkDir('/a/c', 'will_be_removed_shortly', 0, 0);
+ component.refreshAllDirectories();
+ const dirsBeforeRefresh = dirsByPath();
+ expect(dirsBeforeRefresh.includes('/a/c/will_be_removed_shortly')).toBe(true);
+ mockData.createdDirs = []; // Mocks the deletion of the directory
+ // Now the deleted directory will be missing on refresh
+ component.refreshAllDirectories();
+ const dirsAfterRefresh = dirsByPath();
+ expect(dirsAfterRefresh.length - dirsBeforeRefresh.length).toBe(-1);
+ expect(dirsAfterRefresh.includes('/a/c/will_be_removed_shortly')).toBe(false);
+ });
+
+ describe('loading indicator', () => {
+ beforeEach(() => {
+ noAsyncUpdate = true;
+ });
+
+ it('should have set loading indicator to false after refreshing all dirs', fakeAsync(() => {
+ component.refreshAllDirectories();
+ expect(component.loadingIndicator).toBe(true);
+ tick(3000); // To resolve all promises
+ expect(component.loadingIndicator).toBe(false);
+ }));
+
+ it('should only update the tree once and not on every call', fakeAsync(() => {
+ const spy = spyOn(component.treeComponent, 'sizeChanged').and.callThrough();
+ component.refreshAllDirectories();
+ expect(spy).toHaveBeenCalledTimes(0);
+ tick(3000); // To resolve all promises
+ // Called during the interval and at the end of timeout
+ expect(spy).toHaveBeenCalledTimes(2);
+ }));
+
+ it('should have set all loaded dirs as attribute names of "indicators"', () => {
+ noAsyncUpdate = false;
+ component.refreshAllDirectories();
+ expect(Object.keys(component.loading).sort()).toEqual(calledPaths);
+ });
+
+ it('should set an indicator to true during load', () => {
+ lsDirSpy.and.callFake(() => new Observable((): null => null));
+ component.refreshAllDirectories();
+ expect(Object.values(component.loading).every((b) => b)).toBe(true);
+ expect(component.loadingIndicator).toBe(true);
+ });
+ });
+ describe('disable create snapshot', () => {
+ let actions: CdTableAction[];
+ beforeEach(() => {
+ actions = component.snapshot.tableActions;
+ mockLib.mkDir('/', 'volumes', 2, 2);
+ mockLib.mkDir('/volumes', 'group1', 2, 2);
+ mockLib.mkDir('/volumes/group1', 'subvol', 2, 2);
+ mockLib.mkDir('/volumes/group1/subvol', 'subfile', 2, 2);
+ });
+
+ const empty = (): CdTableSelection => new CdTableSelection();
+
+ it('should return a descriptive message to explain why it is disabled', () => {
+ const path = '/volumes/group1/subvol/subfile';
+ const res = 'Cannot create snapshots for files/folders in the subvolume subvol';
+ mockLib.selectNode(path);
+ expect(actions[0].disable(empty())).toContain(res);
+ });
+
+ it('should return false if it is not a subvolume node', () => {
+ const testCases = [
+ '/volumes/group1/subvol',
+ '/volumes/group1',
+ '/volumes',
+ '/',
+ '/a',
+ '/a/b'
+ ];
+ testCases.forEach((testCase) => {
+ mockLib.selectNode(testCase);
+ expect(actions[0].disable(empty())).toBeFalsy();
+ });
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts
new file mode 100644
index 000000000..4ae8a159a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts
@@ -0,0 +1,733 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { AbstractControl, Validators } from '@angular/forms';
+
+import {
+ ITreeOptions,
+ TreeComponent,
+ TreeModel,
+ TreeNode,
+ TREE_ACTIONS
+} from '@circlon/angular-tree-component';
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import moment from 'moment';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+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 { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdFormModalFieldConfig } from '~/app/shared/models/cd-form-modal-field-config';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import {
+ CephfsDir,
+ CephfsQuotas,
+ CephfsSnapshot
+} from '~/app/shared/models/cephfs-directory-models';
+import { Permission } from '~/app/shared/models/permissions';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+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';
+
+class QuotaSetting {
+ row: {
+ // Used in quota table
+ name: string;
+ value: number | string;
+ originPath: string;
+ };
+ quotaKey: string;
+ dirValue: number;
+ nextTreeMaximum: {
+ value: number;
+ path: string;
+ };
+}
+
+@Component({
+ selector: 'cd-cephfs-directories',
+ templateUrl: './cephfs-directories.component.html',
+ styleUrls: ['./cephfs-directories.component.scss']
+})
+export class CephfsDirectoriesComponent implements OnInit, OnChanges {
+ @ViewChild(TreeComponent)
+ treeComponent: TreeComponent;
+ @ViewChild('origin', { static: true })
+ originTmpl: TemplateRef<any>;
+
+ @Input()
+ id: number;
+
+ private modalRef: NgbModalRef;
+ private dirs: CephfsDir[];
+ private nodeIds: { [path: string]: CephfsDir };
+ private requestedPaths: string[];
+ private loadingTimeout: any;
+
+ icons = Icons;
+ loadingIndicator = false;
+ loading = {};
+ treeOptions: ITreeOptions = {
+ useVirtualScroll: true,
+ getChildren: (node: TreeNode): Promise<any[]> => {
+ return this.updateDirectory(node.id);
+ },
+ actionMapping: {
+ mouse: {
+ click: this.selectAndShowNode.bind(this),
+ expanderClick: this.selectAndShowNode.bind(this)
+ }
+ }
+ };
+
+ permission: Permission;
+ selectedDir: CephfsDir;
+ settings: QuotaSetting[];
+ quota: {
+ columns: CdTableColumn[];
+ selection: CdTableSelection;
+ tableActions: CdTableAction[];
+ updateSelection: Function;
+ };
+ snapshot: {
+ columns: CdTableColumn[];
+ selection: CdTableSelection;
+ tableActions: CdTableAction[];
+ updateSelection: Function;
+ };
+ nodes: any[];
+ alreadyExists: boolean;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private modalService: ModalService,
+ private cephfsService: CephfsService,
+ private cdDatePipe: CdDatePipe,
+ private actionLabels: ActionLabelsI18n,
+ private notificationService: NotificationService,
+ private dimlessBinaryPipe: DimlessBinaryPipe
+ ) {}
+
+ private selectAndShowNode(tree: TreeModel, node: TreeNode, $event: any) {
+ TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
+ this.selectNode(node);
+ }
+
+ private selectNode(node: TreeNode) {
+ TREE_ACTIONS.TOGGLE_ACTIVE(undefined, node, undefined);
+ this.selectedDir = this.getDirectory(node);
+ if (node.id === '/') {
+ return;
+ }
+ this.setSettings(node);
+ }
+
+ ngOnInit() {
+ this.permission = this.authStorageService.getPermissions().cephfs;
+ this.setUpQuotaTable();
+ this.setUpSnapshotTable();
+ }
+
+ private setUpQuotaTable() {
+ this.quota = {
+ columns: [
+ {
+ prop: 'row.name',
+ name: $localize`Name`,
+ flexGrow: 1
+ },
+ {
+ prop: 'row.value',
+ name: $localize`Value`,
+ sortable: false,
+ flexGrow: 1
+ },
+ {
+ prop: 'row.originPath',
+ name: $localize`Origin`,
+ sortable: false,
+ cellTemplate: this.originTmpl,
+ flexGrow: 1
+ }
+ ],
+ selection: new CdTableSelection(),
+ updateSelection: (selection: CdTableSelection) => {
+ this.quota.selection = selection;
+ },
+ tableActions: [
+ {
+ name: this.actionLabels.SET,
+ icon: Icons.edit,
+ permission: 'update',
+ visible: (selection) =>
+ !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
+ click: () => this.updateQuotaModal()
+ },
+ {
+ name: this.actionLabels.UPDATE,
+ icon: Icons.edit,
+ permission: 'update',
+ visible: (selection) => selection.first() && selection.first().dirValue > 0,
+ click: () => this.updateQuotaModal()
+ },
+ {
+ name: this.actionLabels.UNSET,
+ icon: Icons.destroy,
+ permission: 'update',
+ disable: (selection) =>
+ !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
+ click: () => this.unsetQuotaModal()
+ }
+ ]
+ };
+ }
+
+ private setUpSnapshotTable() {
+ this.snapshot = {
+ columns: [
+ {
+ prop: 'name',
+ name: $localize`Name`,
+ flexGrow: 1
+ },
+ {
+ prop: 'path',
+ name: $localize`Path`,
+ isHidden: true,
+ flexGrow: 2
+ },
+ {
+ prop: 'created',
+ name: $localize`Created`,
+ flexGrow: 1,
+ pipe: this.cdDatePipe
+ }
+ ],
+ selection: new CdTableSelection(),
+ updateSelection: (selection: CdTableSelection) => {
+ this.snapshot.selection = selection;
+ },
+ tableActions: [
+ {
+ name: this.actionLabels.CREATE,
+ icon: Icons.add,
+ permission: 'create',
+ canBePrimary: (selection) => !selection.hasSelection,
+ click: () => this.createSnapshot(),
+ disable: () => this.disableCreateSnapshot()
+ },
+ {
+ name: this.actionLabels.DELETE,
+ icon: Icons.destroy,
+ permission: 'delete',
+ click: () => this.deleteSnapshotModal(),
+ canBePrimary: (selection) => selection.hasSelection,
+ disable: (selection) => !selection.hasSelection
+ }
+ ]
+ };
+ }
+
+ private disableCreateSnapshot(): string | boolean {
+ const folders = this.selectedDir.path.split('/').slice(1);
+ // With deph of 4 or more we have the subvolume files/folders for which we cannot create
+ // a snapshot. Somehow, you can create a snapshot of the subvolume but not its files.
+ if (folders.length >= 4 && folders[0] === 'volumes') {
+ return $localize`Cannot create snapshots for files/folders in the subvolume ${folders[2]}`;
+ }
+ return false;
+ }
+
+ ngOnChanges() {
+ this.selectedDir = undefined;
+ this.dirs = [];
+ this.requestedPaths = [];
+ this.nodeIds = {};
+ if (this.id) {
+ this.setRootNode();
+ this.firstCall();
+ }
+ }
+
+ private setRootNode() {
+ this.nodes = [
+ {
+ name: '/',
+ id: '/',
+ isExpanded: true
+ }
+ ];
+ }
+
+ private firstCall() {
+ const path = '/';
+ setTimeout(() => {
+ this.getNode(path).loadNodeChildren();
+ }, 10);
+ }
+
+ updateDirectory(path: string): Promise<any[]> {
+ this.unsetLoadingIndicator();
+ if (!this.requestedPaths.includes(path)) {
+ this.requestedPaths.push(path);
+ } else if (this.loading[path] === true) {
+ return undefined; // Path is currently fetched.
+ }
+ return new Promise((resolve) => {
+ this.setLoadingIndicator(path, true);
+ this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
+ this.updateTreeStructure(dirs);
+ this.updateQuotaTable();
+ this.updateTree();
+ resolve(this.getChildren(path));
+ this.setLoadingIndicator(path, false);
+ });
+ });
+ }
+
+ private setLoadingIndicator(path: string, loading: boolean) {
+ this.loading[path] = loading;
+ this.unsetLoadingIndicator();
+ }
+
+ private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
+ return tree.filter((d) => d.parent === path);
+ }
+
+ private getChildren(path: string): any[] {
+ const subTree = this.getSubTree(path);
+ return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
+ this.createNode(dir, subTree)
+ );
+ }
+
+ private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
+ this.nodeIds[dir.path] = dir;
+ if (!subTree) {
+ this.getSubTree(dir.parent);
+ }
+ return {
+ name: dir.name,
+ id: dir.path,
+ hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
+ };
+ }
+
+ private getSubTree(path: string): CephfsDir[] {
+ return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
+ }
+
+ private setSettings(node: TreeNode) {
+ const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
+ value ? (fn ? fn(value) : value) : '';
+
+ this.settings = [
+ this.getQuota(node, 'max_files', readable),
+ this.getQuota(node, 'max_bytes', (value) =>
+ readable(value, (v) => this.dimlessBinaryPipe.transform(v))
+ )
+ ];
+ }
+
+ private getQuota(
+ tree: TreeNode,
+ quotaKey: string,
+ valueConvertFn: (number: number) => number | string
+ ): QuotaSetting {
+ // Get current maximum
+ const currentPath = tree.id;
+ tree = this.getOrigin(tree, quotaKey);
+ const dir = this.getDirectory(tree);
+ const value = dir.quotas[quotaKey];
+ // Get next tree maximum
+ // => The value that isn't changeable through a change of the current directories quota value
+ let nextMaxValue = value;
+ let nextMaxPath = dir.path;
+ if (tree.id === currentPath) {
+ if (tree.parent.id === '/') {
+ // The value will never inherit any other value, so it has no maximum.
+ nextMaxValue = 0;
+ } else {
+ const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
+ nextMaxValue = nextMaxDir.quotas[quotaKey];
+ nextMaxPath = nextMaxDir.path;
+ }
+ }
+ return {
+ row: {
+ name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
+ value: valueConvertFn(value),
+ originPath: value ? dir.path : ''
+ },
+ quotaKey,
+ dirValue: this.nodeIds[currentPath].quotas[quotaKey],
+ nextTreeMaximum: {
+ value: nextMaxValue,
+ path: nextMaxValue ? nextMaxPath : ''
+ }
+ };
+ }
+
+ /**
+ * Get the node where the quota limit originates from in the current node
+ *
+ * Example as it's a recursive method:
+ *
+ * | Path + Value | Call depth | useOrigin? | Output |
+ * |:-------------:|:----------:|:---------------------:|:------:|
+ * | /a/b/c/d (15) | 1st | 2nd (5) < 15 => false | /a/b |
+ * | /a/b/c (20) | 2nd | 3rd (5) < 20 => false | /a/b |
+ * | /a/b (5) | 3rd | 4th (10) < 5 => true | /a/b |
+ * | /a (10) | 4th | 10 => true | /a |
+ *
+ */
+ private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
+ if (tree.parent && tree.parent.id !== '/') {
+ const current = this.getQuotaFromTree(tree, quotaSetting);
+
+ // Get the next used quota and node above the current one (until it hits the root directory)
+ const originTree = this.getOrigin(tree.parent, quotaSetting);
+ const inherited = this.getQuotaFromTree(originTree, quotaSetting);
+
+ // Select if the current quota is in use or the above
+ const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
+ return useOrigin ? originTree : tree;
+ }
+ return tree;
+ }
+
+ private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
+ return this.getDirectory(tree).quotas[quotaSetting];
+ }
+
+ private getDirectory(node: TreeNode): CephfsDir {
+ const path = node.id as string;
+ return this.nodeIds[path];
+ }
+
+ selectOrigin(path: string) {
+ this.selectNode(this.getNode(path));
+ }
+
+ private getNode(path: string): TreeNode {
+ return this.treeComponent.treeModel.getNodeById(path);
+ }
+
+ updateQuotaModal() {
+ const path = this.selectedDir.path;
+ const selection: QuotaSetting = this.quota.selection.first();
+ const nextMax = selection.nextTreeMaximum;
+ const key = selection.quotaKey;
+ const value = selection.dirValue;
+ this.modalService.show(FormModalComponent, {
+ titleText: this.getModalQuotaTitle(
+ value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
+ path
+ ),
+ message: nextMax.value
+ ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
+ nextMax.value,
+ nextMax.path
+ )} is the maximum value to be used.`
+ : undefined,
+ fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
+ submitButtonText: $localize`Save`,
+ onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
+ });
+ }
+
+ private getModalQuotaTitle(action: string, path: string): string {
+ return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
+ }
+
+ private getQuotaName(): string {
+ return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
+ }
+
+ private isBytesQuotaSelected(): boolean {
+ return this.quota.selection.first().quotaKey === 'max_bytes';
+ }
+
+ private getQuotaValueFromPathMsg(value: number, path: string): string {
+ value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
+
+ return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
+ }
+
+ private getQuotaFormField(
+ label: string,
+ name: string,
+ value: number,
+ maxValue: number
+ ): CdFormModalFieldConfig {
+ const isBinary = name === 'max_bytes';
+ const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
+ if (maxValue) {
+ formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
+ }
+ const field: CdFormModalFieldConfig = {
+ type: isBinary ? 'binary' : 'number',
+ label,
+ name,
+ value,
+ validators: formValidators,
+ required: true
+ };
+ if (!isBinary) {
+ field.errors = {
+ min: $localize`Value has to be at least 0 or more`,
+ max: $localize`Value has to be at most ${maxValue} or less`
+ };
+ }
+ return field;
+ }
+
+ private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
+ const path = this.selectedDir.path;
+ const key = this.quota.selection.first().quotaKey;
+ const action =
+ this.selectedDir.quotas[key] === 0
+ ? this.actionLabels.SET
+ : values[key] === 0
+ ? this.actionLabels.UNSET
+ : $localize`Updated`;
+ this.cephfsService.quota(this.id, path, values).subscribe(() => {
+ if (onSuccess) {
+ onSuccess();
+ }
+ this.notificationService.show(
+ NotificationType.success,
+ this.getModalQuotaTitle(action, path)
+ );
+ this.forceDirRefresh();
+ });
+ }
+
+ unsetQuotaModal() {
+ const path = this.selectedDir.path;
+ const selection: QuotaSetting = this.quota.selection.first();
+ const key = selection.quotaKey;
+ const nextMax = selection.nextTreeMaximum;
+ const dirValue = selection.dirValue;
+
+ const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
+ const conclusion =
+ nextMax.value > 0
+ ? nextMax.value > dirValue
+ ? $localize`in order to inherit ${quotaValue}`
+ : $localize`which isn't used because of the inheritance of ${quotaValue}`
+ : $localize`in order to have no quota on the directory`;
+
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, {
+ titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
+ buttonText: this.actionLabels.UNSET,
+ description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
+ dirValue,
+ path
+ )} ${conclusion}.`,
+ onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
+ });
+ }
+
+ createSnapshot() {
+ // Create a snapshot. Auto-generate a snapshot name by default.
+ const path = this.selectedDir.path;
+ this.modalService.show(FormModalComponent, {
+ titleText: $localize`Create Snapshot`,
+ message: $localize`Please enter the name of the snapshot.`,
+ fields: [
+ {
+ type: 'text',
+ name: 'name',
+ value: `${moment().toISOString(true)}`,
+ required: true,
+ validators: [this.validateValue.bind(this)]
+ }
+ ],
+ submitButtonText: $localize`Create Snapshot`,
+ onSubmit: (values: CephfsSnapshot) => {
+ if (!this.alreadyExists) {
+ this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created snapshot '${name}' for '${path}'`
+ );
+ this.forceDirRefresh();
+ });
+ } else {
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Snapshot name '${values.name}' is already in use. Please use another name.`
+ );
+ }
+ }
+ });
+ }
+
+ validateValue(control: AbstractControl) {
+ this.alreadyExists = this.selectedDir.snapshots.some((s) => s.name === control.value);
+ }
+
+ /**
+ * Forces an update of the current selected directory
+ *
+ * As all nodes point by their path on an directory object, the easiest way is to update
+ * the objects by merge with their latest change.
+ */
+ private forceDirRefresh(path?: string) {
+ if (!path) {
+ const dir = this.selectedDir;
+ if (!dir) {
+ throw new Error('This function can only be called without path if an selection was made');
+ }
+ // Parent has to be called in order to update the object referring
+ // to the current selected directory
+ path = dir.parent ? dir.parent : dir.path;
+ }
+ const node = this.getNode(path);
+ node.loadNodeChildren();
+ }
+
+ private updateTreeStructure(dirs: CephfsDir[]) {
+ const getChildrenAndPaths = (
+ directories: CephfsDir[],
+ parent: string
+ ): { children: CephfsDir[]; paths: string[] } => {
+ const children = directories.filter((d) => d.parent === parent);
+ const paths = children.map((d) => d.path);
+ return { children, paths };
+ };
+
+ const parents = _.uniq(dirs.map((d) => d.parent).sort());
+ parents.forEach((p) => {
+ const received = getChildrenAndPaths(dirs, p);
+ const cached = getChildrenAndPaths(this.dirs, p);
+
+ cached.children.forEach((d) => {
+ if (!received.paths.includes(d.path)) {
+ this.removeOldDirectory(d);
+ }
+ });
+ received.children.forEach((d) => {
+ if (cached.paths.includes(d.path)) {
+ this.updateExistingDirectory(cached.children, d);
+ } else {
+ this.addNewDirectory(d);
+ }
+ });
+ });
+ }
+
+ private removeOldDirectory(rmDir: CephfsDir) {
+ const path = rmDir.path;
+ // Remove directory from local variables
+ _.remove(this.dirs, (d) => d.path === path);
+ delete this.nodeIds[path];
+ this.updateDirectoriesParentNode(rmDir);
+ }
+
+ private updateDirectoriesParentNode(dir: CephfsDir) {
+ const parent = dir.parent;
+ if (!parent) {
+ return;
+ }
+ const node = this.getNode(parent);
+ if (!node) {
+ // Node will not be found for new sub sub directories - this is the intended behaviour
+ return;
+ }
+ const children = this.getChildren(parent);
+ node.data.children = children;
+ node.data.hasChildren = children.length > 0;
+ this.treeComponent.treeModel.update();
+ }
+
+ private addNewDirectory(newDir: CephfsDir) {
+ this.dirs.push(newDir);
+ this.nodeIds[newDir.path] = newDir;
+ this.updateDirectoriesParentNode(newDir);
+ }
+
+ private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
+ const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
+ Object.assign(currentDirObject, updatedDir);
+ }
+
+ private updateQuotaTable() {
+ const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
+ if (node && node.id !== '/') {
+ this.setSettings(node);
+ }
+ }
+
+ private updateTree(force: boolean = false) {
+ if (this.loadingIndicator && !force) {
+ // In order to make the page scrollable during load, the render cycle for each node
+ // is omitted and only be called if all updates were loaded.
+ return;
+ }
+ this.treeComponent.treeModel.update();
+ this.nodes = [...this.nodes];
+ this.treeComponent.sizeChanged();
+ }
+
+ deleteSnapshotModal() {
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: $localize`CephFs Snapshot`,
+ itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
+ submitAction: () => this.deleteSnapshot()
+ });
+ }
+
+ deleteSnapshot() {
+ const path = this.selectedDir.path;
+ this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
+ const name = snapshot.name;
+ this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Deleted snapshot '${name}' for '${path}'`
+ );
+ });
+ });
+ this.modalRef.close();
+ this.forceDirRefresh();
+ }
+
+ refreshAllDirectories() {
+ // In order to make the page scrollable during load, the render cycle for each node
+ // is omitted and only be called if all updates were loaded.
+ this.loadingIndicator = true;
+ this.requestedPaths.map((path) => this.forceDirRefresh(path));
+ const interval = setInterval(() => {
+ this.updateTree(true);
+ if (!this.loadingIndicator) {
+ clearInterval(interval);
+ }
+ }, 3000);
+ }
+
+ unsetLoadingIndicator() {
+ if (!this.loadingIndicator) {
+ return;
+ }
+ clearTimeout(this.loadingTimeout);
+ this.loadingTimeout = setTimeout(() => {
+ const loading = Object.values(this.loading).some((l) => l);
+ if (loading) {
+ return this.unsetLoadingIndicator();
+ }
+ this.loadingIndicator = false;
+ this.updateTree();
+ // The problem is that we can't subscribe to an useful updated tree event and the time
+ // between fetching all calls and rebuilding the tree can take some time
+ }, 3000);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html
new file mode 100644
index 000000000..05960e87f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html
@@ -0,0 +1,14 @@
+<cd-table [data]="filesystems"
+ columnMode="flex"
+ [columns]="columns"
+ (fetchData)="loadFilesystems($event)"
+ identifier="id"
+ forceIdentifier="true"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-cephfs-tabs cdTableDetail
+ [selection]="expandedRow">
+ </cd-cephfs-tabs>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts
new file mode 100644
index 000000000..793651081
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.spec.ts
@@ -0,0 +1,35 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { Component, Input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CephfsListComponent } from './cephfs-list.component';
+
+@Component({ selector: 'cd-cephfs-tabs', template: '' })
+class CephfsTabsStubComponent {
+ @Input()
+ selection: CdTableSelection;
+}
+
+describe('CephfsListComponent', () => {
+ let component: CephfsListComponent;
+ let fixture: ComponentFixture<CephfsListComponent>;
+
+ configureTestBed({
+ imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule],
+ declarations: [CephfsListComponent, CephfsTabsStubComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts
new file mode 100644
index 000000000..8d19d394c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.ts
@@ -0,0 +1,61 @@
+import { Component, OnInit } from '@angular/core';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+
+@Component({
+ selector: 'cd-cephfs-list',
+ templateUrl: './cephfs-list.component.html',
+ styleUrls: ['./cephfs-list.component.scss']
+})
+export class CephfsListComponent extends ListWithDetails implements OnInit {
+ columns: CdTableColumn[];
+ filesystems: any = [];
+ selection = new CdTableSelection();
+
+ constructor(private cephfsService: CephfsService, private cdDatePipe: CdDatePipe) {
+ super();
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'mdsmap.fs_name',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Created`,
+ prop: 'mdsmap.created',
+ flexGrow: 2,
+ pipe: this.cdDatePipe
+ },
+ {
+ name: $localize`Enabled`,
+ prop: 'mdsmap.enabled',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.checkIcon
+ }
+ ];
+ }
+
+ loadFilesystems(context: CdTableFetchDataContext) {
+ this.cephfsService.list().subscribe(
+ (resp: any[]) => {
+ this.filesystems = resp;
+ },
+ () => {
+ context.error();
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html
new file mode 100644
index 000000000..7a222a100
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html
@@ -0,0 +1,47 @@
+<ng-container *ngIf="selection">
+ <ul ngbNav
+ #nav="ngbNav"
+ (navChange)="softRefresh()"
+ class="nav-tabs"
+ cdStatefulTab="cephfs-tabs">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <cd-cephfs-detail [data]="details">
+ </cd-cephfs-detail>
+ </ng-template>
+ </li>
+ <li ngbNavItem="clients">
+ <a ngbNavLink>
+ <ng-container i18n>Clients</ng-container>
+ <span class="badge badge-pill badge-tab ml-1">{{ clients.data.length }}</span>
+ </a>
+ <ng-template ngbNavContent>
+ <cd-cephfs-clients [id]="id"
+ [clients]="clients"
+ (triggerApiUpdate)="refresh()">
+ </cd-cephfs-clients>
+ </ng-template>
+ </li>
+ <li ngbNavItem="directories">
+ <a ngbNavLink
+ i18n>Directories</a>
+ <ng-template ngbNavContent>
+ <cd-cephfs-directories [id]="id"></cd-cephfs-directories>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-details">
+ <a ngbNavLink
+ i18n>Performance Details</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'mds-performance?var-mds_servers=mds.' + grafanaId"
+ uid="tbO9LAiZz"
+ grafanaStyle="one">
+ </cd-grafana>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts
new file mode 100644
index 000000000..6a8a3991b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts
@@ -0,0 +1,215 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { Component, Input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TreeModule } from '@circlon/angular-tree-component';
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CephfsClientsComponent } from '../cephfs-clients/cephfs-clients.component';
+import { CephfsDetailComponent } from '../cephfs-detail/cephfs-detail.component';
+import { CephfsDirectoriesComponent } from '../cephfs-directories/cephfs-directories.component';
+import { CephfsTabsComponent } from './cephfs-tabs.component';
+
+describe('CephfsTabsComponent', () => {
+ let component: CephfsTabsComponent;
+ let fixture: ComponentFixture<CephfsTabsComponent>;
+ let service: CephfsService;
+ let data: {
+ standbys: string;
+ pools: any[];
+ ranks: any[];
+ mdsCounters: object;
+ name: string;
+ clients: { status: ViewCacheStatus; data: any[] };
+ };
+
+ let old: any;
+ const getReload: any = () => component['reloadSubscriber'];
+ const setReload = (sth?: any) => (component['reloadSubscriber'] = sth);
+ const mockRunOutside = () => {
+ component['subscribeInterval'] = () => {
+ // It's mocked because the rxjs timer subscription isn't called through the use of 'tick'.
+ setReload({
+ unsubscribed: false,
+ unsubscribe: () => {
+ old = getReload();
+ getReload().unsubscribed = true;
+ setReload();
+ }
+ });
+ component.refresh();
+ };
+ };
+
+ const setSelection = (selection: any) => {
+ component.selection = selection;
+ component.ngOnChanges();
+ };
+
+ const selectFs = (id: number, name: string) => {
+ setSelection({
+ id,
+ mdsmap: {
+ info: {
+ something: {
+ name
+ }
+ }
+ }
+ });
+ };
+
+ const updateData = () => {
+ component['data'] = _.cloneDeep(data);
+ component.softRefresh();
+ };
+
+ @Component({ selector: 'cd-cephfs-chart', template: '' })
+ class CephfsChartStubComponent {
+ @Input()
+ mdsCounter: any;
+ }
+
+ configureTestBed({
+ imports: [
+ SharedModule,
+ NgbNavModule,
+ HttpClientTestingModule,
+ TreeModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [
+ CephfsTabsComponent,
+ CephfsChartStubComponent,
+ CephfsDetailComponent,
+ CephfsDirectoriesComponent,
+ CephfsClientsComponent
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsTabsComponent);
+ component = fixture.componentInstance;
+ component.selection = undefined;
+ data = {
+ standbys: 'b',
+ pools: [{}, {}],
+ ranks: [{}, {}, {}],
+ mdsCounters: { a: { name: 'a', x: [], y: [] } },
+ name: 'someFs',
+ clients: {
+ status: ViewCacheStatus.ValueOk,
+ data: [{}, {}, {}, {}]
+ }
+ };
+ service = TestBed.inject(CephfsService);
+ spyOn(service, 'getTabs').and.callFake(() => of(data));
+
+ fixture.detectChanges();
+ mockRunOutside();
+ setReload(); // Clears rxjs timer subscription
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should resist invalid mds info', () => {
+ setSelection({
+ id: 3,
+ mdsmap: {
+ info: {}
+ }
+ });
+ expect(component.grafanaId).toBe(undefined);
+ });
+
+ it('should find out the grafana id', () => {
+ selectFs(2, 'otherMds');
+ expect(component.grafanaId).toBe('otherMds');
+ });
+
+ it('should set default values on id change before api request', () => {
+ const defaultDetails: Record<string, any> = {
+ standbys: '',
+ pools: [],
+ ranks: [],
+ mdsCounters: {},
+ name: ''
+ };
+ const defaultClients: Record<string, any> = {
+ data: [],
+ status: new TableStatusViewCache(ViewCacheStatus.ValueNone)
+ };
+ component['subscribeInterval'] = () => undefined;
+ updateData();
+ expect(component.clients).not.toEqual(defaultClients);
+ expect(component.details).not.toEqual(defaultDetails);
+ selectFs(2, 'otherMds');
+ expect(component.clients).toEqual(defaultClients);
+ expect(component.details).toEqual(defaultDetails);
+ });
+
+ it('should force data updates on tab change without api requests', () => {
+ const oldClients = component.clients;
+ const oldDetails = component.details;
+ updateData();
+ expect(service.getTabs).toHaveBeenCalledTimes(0);
+ expect(component.details).not.toBe(oldDetails);
+ expect(component.clients).not.toBe(oldClients);
+ });
+
+ describe('handling of id change', () => {
+ beforeEach(() => {
+ setReload(); // Clears rxjs timer subscription
+ selectFs(2, 'otherMds');
+ old = getReload(); // Gets current subscription
+ });
+
+ it('should have called getDetails once', () => {
+ expect(component.details.pools.length).toBe(2);
+ expect(service.getTabs).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not subscribe to an new interval for the same selection', () => {
+ expect(component.id).toBe(2);
+ expect(component.grafanaId).toBe('otherMds');
+ selectFs(2, 'otherMds');
+ expect(component.id).toBe(2);
+ expect(component.grafanaId).toBe('otherMds');
+ expect(getReload()).toBe(old);
+ });
+
+ it('should subscribe to an new interval', () => {
+ selectFs(3, 'anotherMds');
+ expect(getReload()).not.toBe(old); // Holds an new object
+ });
+
+ it('should unsubscribe the old interval if it exists', () => {
+ selectFs(3, 'anotherMds');
+ expect(old.unsubscribed).toBe(true);
+ });
+
+ it('should not unsubscribe if no interval exists', () => {
+ expect(() => component.ngOnDestroy()).not.toThrow();
+ });
+
+ it('should request the details of the new id', () => {
+ expect(service.getTabs).toHaveBeenCalledWith(2);
+ });
+
+ it('should should unsubscribe on deselect', () => {
+ setSelection(undefined);
+ expect(old.unsubscribed).toBe(true);
+ expect(getReload()).toBe(undefined); // Cleared timer subscription
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.ts
new file mode 100644
index 000000000..404ec20aa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.ts
@@ -0,0 +1,130 @@
+import { Component, Input, NgZone, OnChanges, OnDestroy } from '@angular/core';
+
+import _ from 'lodash';
+import { Subscription, timer } from 'rxjs';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-cephfs-tabs',
+ templateUrl: './cephfs-tabs.component.html',
+ styleUrls: ['./cephfs-tabs.component.scss']
+})
+export class CephfsTabsComponent implements OnChanges, OnDestroy {
+ @Input()
+ selection: any;
+
+ // Grafana tab
+ grafanaId: any;
+ grafanaPermission: Permission;
+
+ // Client tab
+ id: number;
+ clients: Record<string, any> = {
+ data: [],
+ status: new TableStatusViewCache(ViewCacheStatus.ValueNone)
+ };
+
+ // Details tab
+ details: Record<string, any> = {
+ standbys: '',
+ pools: [],
+ ranks: [],
+ mdsCounters: {},
+ name: ''
+ };
+
+ private data: any;
+ private reloadSubscriber: Subscription;
+
+ constructor(
+ private ngZone: NgZone,
+ private authStorageService: AuthStorageService,
+ private cephfsService: CephfsService
+ ) {
+ this.grafanaPermission = this.authStorageService.getPermissions().grafana;
+ }
+
+ ngOnChanges() {
+ if (!this.selection) {
+ this.unsubscribeInterval();
+ return;
+ }
+ if (this.selection.id !== this.id) {
+ this.setupSelected(this.selection.id, this.selection.mdsmap.info);
+ }
+ }
+
+ private setupSelected(id: number, mdsInfo: any) {
+ this.id = id;
+ const firstMds: any = _.first(Object.values(mdsInfo));
+ this.grafanaId = firstMds && firstMds['name'];
+ this.details = {
+ standbys: '',
+ pools: [],
+ ranks: [],
+ mdsCounters: {},
+ name: ''
+ };
+ this.clients = {
+ data: [],
+ status: new TableStatusViewCache(ViewCacheStatus.ValueNone)
+ };
+ this.updateInterval();
+ }
+
+ private updateInterval() {
+ this.unsubscribeInterval();
+ this.subscribeInterval();
+ }
+
+ private unsubscribeInterval() {
+ if (this.reloadSubscriber) {
+ this.reloadSubscriber.unsubscribe();
+ }
+ }
+
+ private subscribeInterval() {
+ this.ngZone.runOutsideAngular(
+ () =>
+ (this.reloadSubscriber = timer(0, 5000).subscribe(() =>
+ this.ngZone.run(() => this.refresh())
+ ))
+ );
+ }
+
+ refresh() {
+ this.cephfsService.getTabs(this.id).subscribe(
+ (data: any) => {
+ this.data = data;
+ this.softRefresh();
+ },
+ () => {
+ this.clients.status = new TableStatusViewCache(ViewCacheStatus.ValueException);
+ }
+ );
+ }
+
+ softRefresh() {
+ const data = _.cloneDeep(this.data); // Forces update of tab tables on tab switch
+ // Clients tab
+ this.clients = data.clients;
+ this.clients.status = new TableStatusViewCache(this.clients.status);
+ // Details tab
+ this.details = {
+ standbys: data.standbys,
+ pools: data.pools,
+ ranks: data.ranks,
+ mdsCounters: data.mds_counters,
+ name: data.name
+ };
+ }
+
+ ngOnDestroy() {
+ this.unsubscribeInterval();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
new file mode 100644
index 000000000..41b58a0a3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
@@ -0,0 +1,28 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { TreeModule } from '@circlon/angular-tree-component';
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ChartsModule } from 'ng2-charts';
+
+import { AppRoutingModule } from '~/app/app-routing.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { CephfsChartComponent } from './cephfs-chart/cephfs-chart.component';
+import { CephfsClientsComponent } from './cephfs-clients/cephfs-clients.component';
+import { CephfsDetailComponent } from './cephfs-detail/cephfs-detail.component';
+import { CephfsDirectoriesComponent } from './cephfs-directories/cephfs-directories.component';
+import { CephfsListComponent } from './cephfs-list/cephfs-list.component';
+import { CephfsTabsComponent } from './cephfs-tabs/cephfs-tabs.component';
+
+@NgModule({
+ imports: [CommonModule, SharedModule, AppRoutingModule, ChartsModule, TreeModule, NgbNavModule],
+ declarations: [
+ CephfsDetailComponent,
+ CephfsClientsComponent,
+ CephfsChartComponent,
+ CephfsListComponent,
+ CephfsTabsComponent,
+ CephfsDirectoriesComponent
+ ]
+})
+export class CephfsModule {}
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
new file mode 100644
index 000000000..610bb79ba
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
@@ -0,0 +1,123 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { TreeModule } from '@circlon/angular-tree-component';
+import {
+ NgbActiveModal,
+ NgbDatepickerModule,
+ NgbDropdownModule,
+ NgbNavModule,
+ NgbPopoverModule,
+ NgbTimepickerModule,
+ NgbTooltipModule,
+ NgbTypeaheadModule
+} from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { PerformanceCounterModule } from '../performance-counter/performance-counter.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
+import { ConfigurationDetailsComponent } from './configuration/configuration-details/configuration-details.component';
+import { ConfigurationFormComponent } from './configuration/configuration-form/configuration-form.component';
+import { ConfigurationComponent } from './configuration/configuration.component';
+import { CreateClusterReviewComponent } from './create-cluster/create-cluster-review.component';
+import { CreateClusterComponent } from './create-cluster/create-cluster.component';
+import { CrushmapComponent } from './crushmap/crushmap.component';
+import { HostDetailsComponent } from './hosts/host-details/host-details.component';
+import { HostFormComponent } from './hosts/host-form/host-form.component';
+import { HostsComponent } from './hosts/hosts.component';
+import { InventoryDevicesComponent } from './inventory/inventory-devices/inventory-devices.component';
+import { InventoryComponent } from './inventory/inventory.component';
+import { LogsComponent } from './logs/logs.component';
+import { MgrModulesModule } from './mgr-modules/mgr-modules.module';
+import { MonitorComponent } from './monitor/monitor.component';
+import { OsdCreationPreviewModalComponent } from './osd/osd-creation-preview-modal/osd-creation-preview-modal.component';
+import { OsdDetailsComponent } from './osd/osd-details/osd-details.component';
+import { OsdDevicesSelectionGroupsComponent } from './osd/osd-devices-selection-groups/osd-devices-selection-groups.component';
+import { OsdDevicesSelectionModalComponent } from './osd/osd-devices-selection-modal/osd-devices-selection-modal.component';
+import { OsdFlagsIndivModalComponent } from './osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component';
+import { OsdFlagsModalComponent } from './osd/osd-flags-modal/osd-flags-modal.component';
+import { OsdFormComponent } from './osd/osd-form/osd-form.component';
+import { OsdListComponent } from './osd/osd-list/osd-list.component';
+import { OsdPgScrubModalComponent } from './osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component';
+import { OsdRecvSpeedModalComponent } from './osd/osd-recv-speed-modal/osd-recv-speed-modal.component';
+import { OsdReweightModalComponent } from './osd/osd-reweight-modal/osd-reweight-modal.component';
+import { OsdScrubModalComponent } from './osd/osd-scrub-modal/osd-scrub-modal.component';
+import { ActiveAlertListComponent } from './prometheus/active-alert-list/active-alert-list.component';
+import { PrometheusTabsComponent } from './prometheus/prometheus-tabs/prometheus-tabs.component';
+import { RulesListComponent } from './prometheus/rules-list/rules-list.component';
+import { SilenceFormComponent } from './prometheus/silence-form/silence-form.component';
+import { SilenceListComponent } from './prometheus/silence-list/silence-list.component';
+import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component';
+import { PlacementPipe } from './services/placement.pipe';
+import { ServiceDaemonListComponent } from './services/service-daemon-list/service-daemon-list.component';
+import { ServiceDetailsComponent } from './services/service-details/service-details.component';
+import { ServiceFormComponent } from './services/service-form/service-form.component';
+import { ServicesComponent } from './services/services.component';
+import { TelemetryComponent } from './telemetry/telemetry.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ PerformanceCounterModule,
+ NgbNavModule,
+ SharedModule,
+ RouterModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbTooltipModule,
+ MgrModulesModule,
+ NgbTypeaheadModule,
+ NgbTimepickerModule,
+ TreeModule,
+ CephSharedModule,
+ NgbDatepickerModule,
+ NgbPopoverModule,
+ NgbDropdownModule,
+ NgxPipeFunctionModule
+ ],
+ declarations: [
+ HostsComponent,
+ MonitorComponent,
+ ConfigurationComponent,
+ OsdListComponent,
+ OsdDetailsComponent,
+ OsdScrubModalComponent,
+ OsdFlagsModalComponent,
+ HostDetailsComponent,
+ ConfigurationDetailsComponent,
+ ConfigurationFormComponent,
+ OsdReweightModalComponent,
+ CrushmapComponent,
+ LogsComponent,
+ OsdRecvSpeedModalComponent,
+ OsdPgScrubModalComponent,
+ OsdRecvSpeedModalComponent,
+ SilenceFormComponent,
+ SilenceListComponent,
+ SilenceMatcherModalComponent,
+ ServicesComponent,
+ InventoryComponent,
+ HostFormComponent,
+ OsdFormComponent,
+ OsdDevicesSelectionModalComponent,
+ InventoryDevicesComponent,
+ OsdDevicesSelectionGroupsComponent,
+ OsdCreationPreviewModalComponent,
+ RulesListComponent,
+ ActiveAlertListComponent,
+ ServiceDetailsComponent,
+ ServiceDaemonListComponent,
+ TelemetryComponent,
+ PrometheusTabsComponent,
+ ServiceFormComponent,
+ OsdFlagsIndivModalComponent,
+ PlacementPipe,
+ CreateClusterComponent,
+ CreateClusterReviewComponent
+ ],
+ providers: [NgbActiveModal]
+})
+export class ClusterModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.html
new file mode 100755
index 000000000..8debf9dc6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.html
@@ -0,0 +1,105 @@
+<ng-container *ngIf="selection">
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Name</td>
+ <td class="w-75">{{ selection.name }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Description</td>
+ <td>{{ selection.desc }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Long description</td>
+ <td>{{ selection.long_desc }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Current values</td>
+ <td>
+ <span *ngFor="let conf of selection.value; last as isLast">
+ {{ conf.section }}: {{ conf.value }}{{ !isLast ? "," : "" }}<br />
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Default</td>
+ <td>{{ selection.default }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Daemon default</td>
+ <td>{{ selection.daemon_default }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Type</td>
+ <td>{{ selection.type }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Min</td>
+ <td>{{ selection.min }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Max</td>
+ <td>{{ selection.max }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Flags</td>
+ <td>
+ <span *ngFor="let flag of selection.flags">
+ <span title="{{ flags[flag] }}">
+ <span class="badge badge-dark mr-2">{{ flag | uppercase }}</span>
+ </span>
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Services</td>
+ <td>
+ <span *ngFor="let service of selection.services">
+ <span class="badge badge-dark mr-2">{{ service }}</span>
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Source</td>
+ <td>{{ selection.source }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Level</td>
+ <td>{{ selection.level }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Can be updated at runtime (editable)</td>
+ <td>{{ selection.can_update_at_runtime | booleanText }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Tags</td>
+ <td>{{ selection.tags }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Enum values</td>
+ <td>{{ selection.enum_values }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">See also</td>
+ <td>{{ selection.see_also }}</td>
+ </tr>
+ </tbody>
+ </table>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.scss
new file mode 100755
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.spec.ts
new file mode 100755
index 000000000..4902602a8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.spec.ts
@@ -0,0 +1,26 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DataTableModule } from '~/app/shared/datatable/datatable.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ConfigurationDetailsComponent } from './configuration-details.component';
+
+describe('ConfigurationDetailsComponent', () => {
+ let component: ConfigurationDetailsComponent;
+ let fixture: ComponentFixture<ConfigurationDetailsComponent>;
+
+ configureTestBed({
+ declarations: [ConfigurationDetailsComponent],
+ imports: [DataTableModule, SharedModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ConfigurationDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.ts
new file mode 100755
index 000000000..0d4b67d43
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-details/configuration-details.component.ts
@@ -0,0 +1,29 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import _ from 'lodash';
+
+@Component({
+ selector: 'cd-configuration-details',
+ templateUrl: './configuration-details.component.html',
+ styleUrls: ['./configuration-details.component.scss']
+})
+export class ConfigurationDetailsComponent implements OnChanges {
+ @Input()
+ selection: any;
+ flags = {
+ runtime: $localize`The value can be updated at runtime.`,
+ no_mon_update: $localize`Daemons/clients do not pull this value from the
+ monitor config database. We disallow setting this option via 'ceph config
+ set ...'. This option should be configured via ceph.conf or via the
+ command line.`,
+ startup: $localize`Option takes effect only during daemon startup.`,
+ cluster_create: $localize`Option only affects cluster creation.`,
+ create: $localize`Option only affects daemon creation.`
+ };
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.selection.services = _.split(this.selection.services, ',');
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model.ts
new file mode 100644
index 000000000..bca65a887
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model.ts
@@ -0,0 +1,4 @@
+export class ConfigFormCreateRequestModel {
+ name: string;
+ value: Array<any> = [];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html
new file mode 100644
index 000000000..72c717942
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html
@@ -0,0 +1,160 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="configForm"
+ #formDir="ngForm"
+ [formGroup]="configForm"
+ novalidate>
+ <div class="card">
+ <div class="card-header">
+ <ng-container i18>Edit</ng-container> {{ configForm.getValue('name') }}
+ </div>
+
+ <div class="card-body">
+ <!-- Name -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label">Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ id="name"
+ formControlName="name"
+ readonly>
+ </div>
+ </div>
+
+ <!-- Description -->
+ <div class="form-group row"
+ *ngIf="configForm.getValue('desc')">
+ <label i18n
+ class="cd-col-form-label">Description</label>
+ <div class="cd-col-form-input">
+ <textarea class="form-control resize-vertical"
+ id="desc"
+ formControlName="desc"
+ readonly>
+ </textarea>
+ </div>
+ </div>
+
+ <!-- Long description -->
+ <div class="form-group row"
+ *ngIf="configForm.getValue('long_desc')">
+ <label i18n
+ class="cd-col-form-label">Long description</label>
+ <div class="cd-col-form-input">
+ <textarea class="form-control resize-vertical"
+ id="long_desc"
+ formControlName="long_desc"
+ readonly>
+ </textarea>
+ </div>
+ </div>
+
+ <!-- Default -->
+ <div class="form-group row"
+ *ngIf="configForm.getValue('default') !== ''">
+ <label i18n
+ class="cd-col-form-label">Default</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ id="default"
+ formControlName="default"
+ readonly>
+ </div>
+ </div>
+
+ <!-- Daemon default -->
+ <div class="form-group row"
+ *ngIf="configForm.getValue('daemon_default') !== ''">
+ <label i18n
+ class="cd-col-form-label">Daemon default</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ id="daemon_default"
+ formControlName="daemon_default"
+ readonly>
+ </div>
+ </div>
+
+ <!-- Services -->
+ <div class="form-group row"
+ *ngIf="configForm.getValue('services').length > 0">
+ <label i18n
+ class="cd-col-form-label">Services</label>
+ <div class="cd-col-form-input">
+ <span *ngFor="let service of configForm.getValue('services')"
+ class="form-component-badge">
+ <span class="badge badge-dark">{{ service }}</span>
+ </span>
+ </div>
+ </div>
+
+ <!-- Values -->
+ <div formGroupName="values">
+ <h3 i18n
+ class="cd-header">Values</h3>
+ <ng-container *ngFor="let section of availSections">
+ <div class="form-group row"
+ *ngIf="type === 'bool'">
+ <label class="cd-col-form-label"
+ [for]="section">{{ section }}
+ </label>
+ <div class="cd-col-form-input">
+ <select id="pool"
+ name="pool"
+ class="form-control"
+ [formControlName]="section">
+ <option [ngValue]="null"
+ i18n>-- Default --</option>
+ <option [ngValue]="true"
+ i18n>true</option>
+ <option [ngValue]="false"
+ i18n>false</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="type !== 'bool'">
+ <label class="cd-col-form-label"
+ [for]="section">{{ section }}
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ [type]="inputType"
+ [id]="section"
+ [placeholder]="humanReadableType"
+ [formControlName]="section"
+ [step]="getStep(type, this.configForm.getValue(section))">
+ <span class="invalid-feedback"
+ *ngIf="configForm.showError(section, formDir, 'pattern')">
+ {{ patternHelpText }}
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="configForm.showError(section, formDir, 'invalidUuid')">
+ {{ patternHelpText }}
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="configForm.showError(section, formDir, 'max')"
+ i18n>The entered value is too high! It must not be greater than {{ maxValue }}.</span>
+ <span class="invalid-feedback"
+ *ngIf="configForm.showError(section, formDir, 'min')"
+ i18n>The entered value is too low! It must not be lower than {{ minValue }}.</span>
+ </div>
+ </div>
+ </ng-container>
+ </div>
+ </div>
+ <!-- Footer -->
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="configForm"
+ [submitText]="actionLabels.UPDATE"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss
new file mode 100644
index 000000000..ed2945d1d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss
@@ -0,0 +1,12 @@
+.form-component-badge {
+ display: block;
+ height: 34px;
+
+ span {
+ margin-top: 7px;
+ }
+}
+
+.resize-vertical {
+ resize: vertical;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.spec.ts
new file mode 100644
index 000000000..23fab84cc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.spec.ts
@@ -0,0 +1,106 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { ConfigFormModel } from '~/app/shared/components/config-option/config-option.model';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ConfigurationFormComponent } from './configuration-form.component';
+
+describe('ConfigurationFormComponent', () => {
+ let component: ConfigurationFormComponent;
+ let fixture: ComponentFixture<ConfigurationFormComponent>;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ SharedModule
+ ],
+ declarations: [ConfigurationFormComponent],
+ providers: [
+ {
+ provide: ActivatedRoute
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ConfigurationFormComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('getValidators', () => {
+ it('should return a validator for types float, addr and uuid', () => {
+ const types = ['float', 'addr', 'uuid'];
+
+ types.forEach((valType) => {
+ const configOption = new ConfigFormModel();
+ configOption.type = valType;
+
+ const ret = component.getValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.length).toBe(1);
+ });
+ });
+
+ it('should not return a validator for types str and bool', () => {
+ const types = ['str', 'bool'];
+
+ types.forEach((valType) => {
+ const configOption = new ConfigFormModel();
+ configOption.type = valType;
+
+ const ret = component.getValidators(configOption);
+ expect(ret).toBeUndefined();
+ });
+ });
+
+ it('should return a pattern and a min validator', () => {
+ const configOption = new ConfigFormModel();
+ configOption.type = 'int';
+ configOption.min = 2;
+
+ const ret = component.getValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.length).toBe(2);
+ expect(component.minValue).toBe(2);
+ expect(component.maxValue).toBeUndefined();
+ });
+
+ it('should return a pattern and a max validator', () => {
+ const configOption = new ConfigFormModel();
+ configOption.type = 'int';
+ configOption.max = 5;
+
+ const ret = component.getValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.length).toBe(2);
+ expect(component.minValue).toBeUndefined();
+ expect(component.maxValue).toBe(5);
+ });
+
+ it('should return multiple validators', () => {
+ const configOption = new ConfigFormModel();
+ configOption.type = 'float';
+ configOption.max = 5.2;
+ configOption.min = 1.5;
+
+ const ret = component.getValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.length).toBe(3);
+ expect(component.minValue).toBe(1.5);
+ expect(component.maxValue).toBe(5.2);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts
new file mode 100644
index 000000000..18099109d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts
@@ -0,0 +1,172 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup, ValidatorFn } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { ConfigFormModel } from '~/app/shared/components/config-option/config-option.model';
+import { ConfigOptionTypes } from '~/app/shared/components/config-option/config-option.types';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { ConfigFormCreateRequestModel } from './configuration-form-create-request.model';
+
+@Component({
+ selector: 'cd-configuration-form',
+ templateUrl: './configuration-form.component.html',
+ styleUrls: ['./configuration-form.component.scss']
+})
+export class ConfigurationFormComponent extends CdForm implements OnInit {
+ configForm: CdFormGroup;
+ response: ConfigFormModel;
+ type: string;
+ inputType: string;
+ humanReadableType: string;
+ minValue: number;
+ maxValue: number;
+ patternHelpText: string;
+ availSections = ['global', 'mon', 'mgr', 'osd', 'mds', 'client'];
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private route: ActivatedRoute,
+ private router: Router,
+ private configService: ConfigurationService,
+ private notificationService: NotificationService
+ ) {
+ super();
+ this.createForm();
+ }
+
+ createForm() {
+ const formControls = {
+ name: new FormControl({ value: null }),
+ desc: new FormControl({ value: null }),
+ long_desc: new FormControl({ value: null }),
+ values: new FormGroup({}),
+ default: new FormControl({ value: null }),
+ daemon_default: new FormControl({ value: null }),
+ services: new FormControl([])
+ };
+
+ this.availSections.forEach((section) => {
+ formControls.values.addControl(section, new FormControl(null));
+ });
+
+ this.configForm = new CdFormGroup(formControls);
+ }
+
+ ngOnInit() {
+ this.route.params.subscribe((params: { name: string }) => {
+ const configName = params.name;
+ this.configService.get(configName).subscribe((resp: ConfigFormModel) => {
+ this.setResponse(resp);
+ this.loadingReady();
+ });
+ });
+ }
+
+ getValidators(configOption: any): ValidatorFn[] {
+ const typeValidators = ConfigOptionTypes.getTypeValidators(configOption);
+ if (typeValidators) {
+ this.patternHelpText = typeValidators.patternHelpText;
+
+ if ('max' in typeValidators && typeValidators.max !== '') {
+ this.maxValue = typeValidators.max;
+ }
+
+ if ('min' in typeValidators && typeValidators.min !== '') {
+ this.minValue = typeValidators.min;
+ }
+
+ return typeValidators.validators;
+ }
+
+ return undefined;
+ }
+
+ getStep(type: string, value: number): number | undefined {
+ return ConfigOptionTypes.getTypeStep(type, value);
+ }
+
+ setResponse(response: ConfigFormModel) {
+ this.response = response;
+ const validators = this.getValidators(response);
+
+ this.configForm.get('name').setValue(response.name);
+ this.configForm.get('desc').setValue(response.desc);
+ this.configForm.get('long_desc').setValue(response.long_desc);
+ this.configForm.get('default').setValue(response.default);
+ this.configForm.get('daemon_default').setValue(response.daemon_default);
+ this.configForm.get('services').setValue(response.services);
+
+ if (this.response.value) {
+ this.response.value.forEach((value) => {
+ // Check value type. If it's a boolean value we need to convert it because otherwise we
+ // would use the string representation. That would cause issues for e.g. checkboxes.
+ let sectionValue = null;
+ if (value.value === 'true') {
+ sectionValue = true;
+ } else if (value.value === 'false') {
+ sectionValue = false;
+ } else {
+ sectionValue = value.value;
+ }
+ this.configForm.get('values').get(value.section).setValue(sectionValue);
+ });
+ }
+
+ this.availSections.forEach((section) => {
+ this.configForm.get('values').get(section).setValidators(validators);
+ });
+
+ const currentType = ConfigOptionTypes.getType(response.type);
+ this.type = currentType.name;
+ this.inputType = currentType.inputType;
+ this.humanReadableType = currentType.humanReadable;
+ }
+
+ createRequest(): ConfigFormCreateRequestModel | null {
+ const values: any[] = [];
+
+ this.availSections.forEach((section) => {
+ const sectionValue = this.configForm.getValue(section);
+ if (sectionValue !== null && sectionValue !== '') {
+ values.push({ section: section, value: sectionValue });
+ }
+ });
+
+ if (!_.isEqual(this.response.value, values)) {
+ const request = new ConfigFormCreateRequestModel();
+ request.name = this.configForm.getValue('name');
+ request.value = values;
+ return request;
+ }
+
+ return null;
+ }
+
+ submit() {
+ const request = this.createRequest();
+
+ if (request) {
+ this.configService.create(request).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated config option ${request.name}`
+ );
+ this.router.navigate(['/configuration']);
+ },
+ () => {
+ this.configForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ this.router.navigate(['/configuration']);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.html
new file mode 100644
index 000000000..a1eb64963
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.html
@@ -0,0 +1,26 @@
+<cd-table [data]="data"
+ (fetchData)="getConfigurationList($event)"
+ [columns]="columns"
+ [extraFilterableColumns]="filters"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-configuration-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-configuration-details>
+</cd-table>
+
+<ng-template #confValTpl
+ let-value="value">
+ <span *ngIf="value">
+ <span *ngFor="let conf of value; last as isLast">
+ {{ conf.section }}: {{ conf.value }}{{ !isLast ? "," : "" }}<br />
+ </span>
+ </span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.scss
new file mode 100644
index 000000000..33f2ebaa2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.scss
@@ -0,0 +1,16 @@
+.filter {
+ padding-right: 8px;
+}
+
+.fa-stack {
+ font-size: 0.79rem;
+
+ .fa-stack-1x {
+ margin-left: 8px;
+ margin-top: 5px;
+ }
+}
+
+::ng-deep cd-configuration datatable-body-cell.wrap {
+ word-break: break-all;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.spec.ts
new file mode 100644
index 000000000..56e374cef
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.spec.ts
@@ -0,0 +1,46 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ConfigurationDetailsComponent } from './configuration-details/configuration-details.component';
+import { ConfigurationComponent } from './configuration.component';
+
+describe('ConfigurationComponent', () => {
+ let component: ConfigurationComponent;
+ let fixture: ComponentFixture<ConfigurationComponent>;
+
+ configureTestBed({
+ declarations: [ConfigurationComponent, ConfigurationDetailsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ FormsModule,
+ NgbNavModule,
+ HttpClientTestingModule,
+ RouterTestingModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ConfigurationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should check header text', () => {
+ expect(fixture.debugElement.query(By.css('.datatable-header')).nativeElement.textContent).toBe(
+ ['Name', 'Description', 'Current value', 'Default', 'Editable'].join('')
+ );
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.ts
new file mode 100644
index 000000000..a57603d4c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration.component.ts
@@ -0,0 +1,149 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-configuration',
+ templateUrl: './configuration.component.html',
+ styleUrls: ['./configuration.component.scss']
+})
+export class ConfigurationComponent extends ListWithDetails implements OnInit {
+ permission: Permission;
+ tableActions: CdTableAction[];
+ data: any[] = [];
+ icons = Icons;
+ columns: CdTableColumn[];
+ selection = new CdTableSelection();
+ filters: CdTableColumn[] = [
+ {
+ name: $localize`Level`,
+ prop: 'level',
+ filterOptions: ['basic', 'advanced', 'dev'],
+ filterInitValue: 'basic',
+ filterPredicate: (row, value) => {
+ enum Level {
+ basic = 0,
+ advanced = 1,
+ dev = 2
+ }
+
+ const levelVal = Level[value];
+
+ return Level[row.level] <= levelVal;
+ }
+ },
+ {
+ name: $localize`Service`,
+ prop: 'services',
+ filterOptions: ['mon', 'mgr', 'osd', 'mds', 'common', 'mds_client', 'rgw'],
+ filterPredicate: (row, value) => {
+ return row.services.includes(value);
+ }
+ },
+ {
+ name: $localize`Source`,
+ prop: 'source',
+ filterOptions: ['mon'],
+ filterPredicate: (row, value) => {
+ if (!row.hasOwnProperty('source')) {
+ return false;
+ }
+ return row.source.includes(value);
+ }
+ },
+ {
+ name: $localize`Modified`,
+ prop: 'modified',
+ filterOptions: ['yes', 'no'],
+ filterPredicate: (row, value) => {
+ if (value === 'yes' && row.hasOwnProperty('value')) {
+ return true;
+ }
+
+ if (value === 'no' && !row.hasOwnProperty('value')) {
+ return true;
+ }
+
+ return false;
+ }
+ }
+ ];
+
+ @ViewChild('confValTpl', { static: true })
+ public confValTpl: TemplateRef<any>;
+ @ViewChild('confFlagTpl')
+ public confFlagTpl: TemplateRef<any>;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private configurationService: ConfigurationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().configOpt;
+ const getConfigOptUri = () =>
+ this.selection.first() && `${encodeURIComponent(this.selection.first().name)}`;
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () => `/configuration/edit/${getConfigOptUri()}`,
+ name: this.actionLabels.EDIT,
+ disable: () => !this.isEditable(this.selection)
+ };
+ this.tableActions = [editAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ { canAutoResize: true, prop: 'name', name: $localize`Name` },
+ { prop: 'desc', name: $localize`Description`, cellClass: 'wrap' },
+ {
+ prop: 'value',
+ name: $localize`Current value`,
+ cellClass: 'wrap',
+ cellTemplate: this.confValTpl
+ },
+ { prop: 'default', name: $localize`Default`, cellClass: 'wrap' },
+ {
+ prop: 'can_update_at_runtime',
+ name: $localize`Editable`,
+ cellTransformation: CellTemplate.checkIcon,
+ flexGrow: 0.4,
+ cellClass: 'text-center'
+ }
+ ];
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ getConfigurationList(context: CdTableFetchDataContext) {
+ this.configurationService.getConfigData().subscribe(
+ (data: any) => {
+ this.data = data;
+ },
+ () => {
+ context.error();
+ }
+ );
+ }
+
+ isEditable(selection: CdTableSelection): boolean {
+ if (selection.selected.length !== 1) {
+ return false;
+ }
+
+ return selection.selected[0].can_update_at_runtime;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.html
new file mode 100644
index 000000000..7fbc67185
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.html
@@ -0,0 +1,54 @@
+<div class="row">
+ <div class="col-lg-3">
+ <fieldset>
+ <legend class="cd-header"
+ i18n>Cluster Resources</legend>
+ <table class="table table-striped">
+ <tr>
+ <td i18n
+ class="bold">Hosts</td>
+ <td>{{ hostsCount }}</td>
+ </tr>
+ <tr>
+ <td>
+ <dl>
+ <dt>
+ <p i18n>Storage Capacity</p>
+ </dt>
+ <dd>
+ <p i18n>Number of devices</p>
+ </dd>
+ <dd>
+ <p i18n>Raw capacity</p>
+ </dd>
+ </dl>
+ </td>
+ <td class="pt-5"><p>{{ totalDevices }}</p><p>
+ {{ totalCapacity | dimlessBinary }}</p></td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">CPUs</td>
+ <td>{{ totalCPUs }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Memory</td>
+ <td>{{ totalMemory }}</td>
+ </tr>
+ </table>
+ </fieldset>
+ </div>
+
+<div class="col-lg-9">
+ <legend i18n
+ class="cd-header">Host Details</legend>
+ <cd-hosts [hiddenColumns]="['services', 'status']"
+ [hideToolHeader]="true"
+ [hideTitle]="true"
+ [hideSubmitBtn]="true"
+ [hasTableDetails]="false"
+ [showGeneralActionsOnly]="true">
+ </cd-hosts>
+</div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.scss
new file mode 100644
index 000000000..beecca096
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.scss
@@ -0,0 +1,5 @@
+cd-hosts {
+ ::ng-deep .nav {
+ display: none;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.spec.ts
new file mode 100644
index 000000000..94d3dd9d6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.spec.ts
@@ -0,0 +1,29 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CoreModule } from '~/app/core/core.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CreateClusterReviewComponent } from './create-cluster-review.component';
+
+describe('CreateClusterReviewComponent', () => {
+ let component: CreateClusterReviewComponent;
+ let fixture: ComponentFixture<CreateClusterReviewComponent>;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule, ToastrModule.forRoot(), CephModule, CoreModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CreateClusterReviewComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts
new file mode 100644
index 000000000..4490b4e44
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts
@@ -0,0 +1,72 @@
+import { Component, OnInit } from '@angular/core';
+
+import _ from 'lodash';
+
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
+
+@Component({
+ selector: 'cd-create-cluster-review',
+ templateUrl: './create-cluster-review.component.html',
+ styleUrls: ['./create-cluster-review.component.scss']
+})
+export class CreateClusterReviewComponent implements OnInit {
+ hosts: object[] = [];
+ hostsCount: number;
+ totalDevices: number;
+ totalCapacity = 0;
+ services: Array<CephServiceSpec> = [];
+ totalCPUs = 0;
+ totalMemory = 0;
+
+ constructor(
+ public wizardStepsService: WizardStepsService,
+ public cephServiceService: CephServiceService,
+ private dimlessBinary: DimlessBinaryPipe,
+ public hostService: HostService,
+ private osdService: OsdService
+ ) {}
+
+ ngOnInit() {
+ let dataDevices = 0;
+ let dataDeviceCapacity = 0;
+ let walDevices = 0;
+ let walDeviceCapacity = 0;
+ let dbDevices = 0;
+ let dbDeviceCapacity = 0;
+
+ this.hostService.list('true').subscribe((resp: object[]) => {
+ this.hosts = resp;
+ this.hostsCount = this.hosts.length;
+ _.forEach(this.hosts, (hostKey) => {
+ this.totalCPUs = this.totalCPUs + hostKey['cpu_count'];
+ // convert to bytes
+ this.totalMemory = this.totalMemory + hostKey['memory_total_kb'] * 1024;
+ });
+ this.totalMemory = this.dimlessBinary.transform(this.totalMemory);
+ });
+
+ if (this.osdService.osdDevices['data']) {
+ dataDevices = this.osdService.osdDevices['data']?.length;
+ dataDeviceCapacity = this.osdService.osdDevices['data']['capacity'];
+ }
+
+ if (this.osdService.osdDevices['wal']) {
+ walDevices = this.osdService.osdDevices['wal']?.length;
+ walDeviceCapacity = this.osdService.osdDevices['wal']['capacity'];
+ }
+
+ if (this.osdService.osdDevices['db']) {
+ dbDevices = this.osdService.osdDevices['db']?.length;
+ dbDeviceCapacity = this.osdService.osdDevices['db']['capacity'];
+ }
+
+ this.totalDevices = dataDevices + walDevices + dbDevices;
+ this.osdService.osdDevices['totalDevices'] = this.totalDevices;
+ this.totalCapacity = dataDeviceCapacity + walDeviceCapacity + dbDeviceCapacity;
+ }
+}
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
new file mode 100644
index 000000000..d7ab567cf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html
@@ -0,0 +1,98 @@
+<div class="container h-75"
+ *ngIf="!startClusterCreation">
+ <div class="row h-100 justify-content-center align-items-center">
+ <div class="blank-page">
+ <!-- htmllint img-req-src="false" -->
+ <img [src]="projectConstants.cephLogo"
+ alt="Ceph"
+ class="img-fluid mx-auto d-block">
+ <h3 class="text-center m-2"
+ i18n>Welcome to {{ projectConstants.projectName }}</h3>
+
+ <div class="m-4">
+ <h4 class="text-center"
+ i18n>Please expand your cluster first</h4>
+ <div class="offset-md-2">
+ <button class="btn btn-accent m-2"
+ name="expand-cluster"
+ (click)="createCluster()"
+ aria-label="Expand Cluster"
+ i18n>Expand Cluster</button>
+ <button class="btn btn-light"
+ name="skip-cluster-creation"
+ aria-label="Skip"
+ (click)="skipClusterCreation()"
+ i18n>Skip</button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="card"
+ *ngIf="startClusterCreation">
+ <div class="card-header"
+ i18n>Expand Cluster</div>
+ <div class="container-fluid">
+ <cd-wizard [stepsTitle]="stepTitles"></cd-wizard>
+ <div class="card-body vertical-line">
+ <ng-container [ngSwitch]="currentStep?.stepIndex">
+ <div *ngSwitchCase="'1'"
+ class="ml-5">
+ <h4 class="title"
+ i18n>Add Hosts</h4>
+ <br>
+ <cd-hosts [hiddenColumns]="['services']"
+ [hideTitle]="true"
+ [hideSubmitBtn]="true"
+ [hasTableDetails]="false"
+ [showGeneralActionsOnly]="true"></cd-hosts>
+ </div>
+ <div *ngSwitchCase="'2'"
+ class="ml-5">
+ <h4 class="title"
+ i18n>Create OSDs</h4>
+ <div class="alignForm">
+ <cd-osd-form [hideTitle]="true"
+ [hideSubmitBtn]="true"
+ (emitDriveGroup)="setDriveGroup($event)"
+ (emitDeploymentOption)="setDeploymentOptions($event)"
+ (emitMode)="setDeploymentMode($event)"></cd-osd-form>
+ </div>
+ </div>
+ <div *ngSwitchCase="'3'"
+ class="ml-5">
+ <h4 class="title"
+ i18n>Create Services</h4>
+ <br>
+ <cd-services [hasDetails]="false"
+ [hiddenServices]="['mon', 'mgr', 'crash', 'agent']"
+ [hiddenColumns]="['status.running', 'status.size', 'status.last_refresh']"
+ [routedModal]="false"></cd-services>
+ </div>
+ <div *ngSwitchCase="'4'"
+ class="ml-5">
+ <cd-create-cluster-review></cd-create-cluster-review>
+ </div>
+ </ng-container>
+ </div>
+ </div>
+ <div class="card-footer">
+ <button class="btn btn-accent m-2 float-right"
+ (click)="onNextStep()"
+ aria-label="Next"
+ i18n>{{ showSubmitButtonLabel() }}</button>
+ <cd-back-button class="m-2 float-right"
+ aria-label="Close"
+ (backAction)="onPreviousStep()"
+ [name]="showCancelButtonLabel()"></cd-back-button>
+ </div>
+</div>
+
+<ng-template #skipConfirmTpl>
+ <span i18n>You are about to skip the cluster expansion process.
+ You’ll need to <strong>navigate through the menu to add hosts and services.</strong></span>
+
+ <div class="mt-4"
+ i18n>Are you sure you want to continue?</div>
+</ng-template>
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
new file mode 100644
index 000000000..313f3193b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss
@@ -0,0 +1,22 @@
+.container-fluid {
+ align-items: flex-start;
+ display: flex;
+ padding-left: 0;
+ width: 100%;
+}
+
+cd-hosts {
+ ::ng-deep .nav {
+ display: none;
+ }
+}
+
+cd-osd-form {
+ ::ng-deep .card {
+ border: 0;
+ }
+
+ ::ng-deep .accordion {
+ margin-left: -1.5rem;
+ }
+}
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
new file mode 100644
index 000000000..0563c4a80
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts
@@ -0,0 +1,154 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CoreModule } from '~/app/core/core.module';
+import { HostService } from '~/app/shared/api/host.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.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';
+
+describe('CreateClusterComponent', () => {
+ let component: CreateClusterComponent;
+ let fixture: ComponentFixture<CreateClusterComponent>;
+ let wizardStepService: WizardStepsService;
+ let hostService: HostService;
+ let osdService: OsdService;
+ let modalServiceShowSpy: jasmine.Spy;
+ const projectConstants: typeof AppConstants = AppConstants;
+
+ configureTestBed(
+ {
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ SharedModule,
+ CoreModule,
+ CephModule
+ ]
+ },
+ [LoadingPanelComponent]
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CreateClusterComponent);
+ component = fixture.componentInstance;
+ wizardStepService = TestBed.inject(WizardStepsService);
+ hostService = TestBed.inject(HostService);
+ osdService = TestBed.inject(OsdService);
+ 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();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ 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 ${projectConstants.projectName}`);
+ });
+
+ it('should show confirmation modal when cluster creation is skipped', () => {
+ component.skipClusterCreation();
+ 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();
+ component.createCluster();
+ fixture.detectChanges();
+ component.onNextStep();
+ fixture.detectChanges();
+ expect(wizardStepServiceSpy).toHaveBeenCalledTimes(1);
+ });
+
+ 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');
+
+ component.onNextStep();
+ fixture.detectChanges();
+ submitBtnLabel = component.showSubmitButtonLabel();
+ expect(submitBtnLabel).toEqual('Next');
+ cancelBtnLabel = component.showCancelButtonLabel();
+ expect(cancelBtnLabel).toEqual('Back');
+
+ component.onNextStep();
+ fixture.detectChanges();
+ submitBtnLabel = component.showSubmitButtonLabel();
+ expect(submitBtnLabel).toEqual('Next');
+ cancelBtnLabel = component.showCancelButtonLabel();
+ expect(cancelBtnLabel).toEqual('Back');
+
+ // Last page of the wizard
+ component.onNextStep();
+ fixture.detectChanges();
+ submitBtnLabel = component.showSubmitButtonLabel();
+ expect(submitBtnLabel).toEqual('Expand Cluster');
+ cancelBtnLabel = component.showCancelButtonLabel();
+ expect(cancelBtnLabel).toEqual('Back');
+ });
+
+ it('should ensure osd creation did not happen when no devices are selected', () => {
+ component.simpleDeployment = false;
+ const osdServiceSpy = spyOn(osdService, 'create').and.callThrough();
+ component.onSubmit();
+ fixture.detectChanges();
+ expect(osdServiceSpy).toBeCalledTimes(0);
+ });
+
+ it('should ensure osd creation did happen when devices are selected', () => {
+ const osdServiceSpy = spyOn(osdService, 'create').and.callThrough();
+ osdService.osdDevices['totalDevices'] = 1;
+ component.onSubmit();
+ fixture.detectChanges();
+ expect(osdServiceSpy).toBeCalledTimes(1);
+ });
+
+ it('should ensure host list call happened', () => {
+ const hostServiceSpy = spyOn(hostService, 'list').and.callThrough();
+ component.onSubmit();
+ expect(hostServiceSpy).toHaveBeenCalledTimes(1);
+ });
+});
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
new file mode 100644
index 000000000..02333c39b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts
@@ -0,0 +1,231 @@
+import {
+ Component,
+ EventEmitter,
+ OnDestroy,
+ OnInit,
+ Output,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+import { Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { forkJoin, Subscription } from 'rxjs';
+import { finalize } from 'rxjs/operators';
+
+import { ClusterService } from '~/app/shared/api/cluster.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { ActionLabelsI18n, AppConstants, URLVerbs } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { DeploymentOptions } from '~/app/shared/models/osd-deployment-options';
+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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
+import { DriveGroup } from '../osd/osd-form/drive-group.model';
+
+@Component({
+ selector: 'cd-create-cluster',
+ templateUrl: './create-cluster.component.html',
+ styleUrls: ['./create-cluster.component.scss']
+})
+export class CreateClusterComponent implements OnInit, OnDestroy {
+ @ViewChild('skipConfirmTpl', { static: true })
+ skipConfirmTpl: TemplateRef<any>;
+ currentStep: WizardStepModel;
+ currentStepSub: Subscription;
+ permissions: Permissions;
+ projectConstants: typeof AppConstants = AppConstants;
+ stepTitles = ['Add Hosts', 'Create OSDs', 'Create Services', 'Review'];
+ startClusterCreation = false;
+ observables: any = [];
+ modalRef: NgbModalRef;
+ driveGroup = new DriveGroup();
+ driveGroups: Object[] = [];
+ deploymentOption: DeploymentOptions;
+ selectedOption = {};
+ simpleDeployment = true;
+
+ @Output()
+ submitAction = new EventEmitter();
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private wizardStepsService: WizardStepsService,
+ private router: Router,
+ private hostService: HostService,
+ private notificationService: NotificationService,
+ private actionLabels: ActionLabelsI18n,
+ private clusterService: ClusterService,
+ private modalService: ModalService,
+ private taskWrapper: TaskWrapperService,
+ private osdService: OsdService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ this.currentStepSub = this.wizardStepsService
+ .getCurrentStep()
+ .subscribe((step: WizardStepModel) => {
+ this.currentStep = step;
+ });
+ this.currentStep.stepIndex = 1;
+ }
+
+ ngOnInit(): void {
+ this.osdService.getDeploymentOptions().subscribe((options) => {
+ this.deploymentOption = options;
+ this.selectedOption = { option: options.recommended_option };
+ });
+ }
+
+ createCluster() {
+ this.startClusterCreation = true;
+ }
+
+ skipClusterCreation() {
+ 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() {
+ this.hostService.list('false').subscribe((hosts) => {
+ hosts.forEach((host) => {
+ const index = host['labels'].indexOf('_no_schedule', 0);
+ if (index > -1) {
+ host['labels'].splice(index, 1);
+ this.observables.push(this.hostService.update(host['hostname'], true, host['labels']));
+ }
+ });
+ 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()
+ });
+ });
+
+ if (this.driveGroup) {
+ const user = this.authStorageService.getUsername();
+ this.driveGroup.setName(`dashboard-${user}-${_.now()}`);
+ this.driveGroups.push(this.driveGroup.spec);
+ }
+
+ if (this.simpleDeployment) {
+ const title = this.deploymentOption?.options[this.selectedOption['option']].title;
+ const trackingId = $localize`${title} deployment`;
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('osd/' + URLVerbs.CREATE, {
+ tracking_id: trackingId
+ }),
+ call: this.osdService.create([this.selectedOption], trackingId, 'predefined')
+ })
+ .subscribe({
+ error: (error) => error.preventDefault(),
+ complete: () => {
+ this.submitAction.emit();
+ }
+ });
+ } else {
+ if (this.osdService.osdDevices['totalDevices'] > 0) {
+ this.driveGroup.setFeature('encrypted', this.selectedOption['encrypted']);
+ const trackingId = _.join(_.map(this.driveGroups, 'service_id'), ', ');
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('osd/' + URLVerbs.CREATE, {
+ tracking_id: trackingId
+ }),
+ call: this.osdService.create(this.driveGroups, trackingId)
+ })
+ .subscribe({
+ error: (error) => error.preventDefault(),
+ complete: () => {
+ this.submitAction.emit();
+ this.osdService.osdDevices = [];
+ }
+ });
+ }
+ }
+ }
+
+ setDriveGroup(driveGroup: DriveGroup) {
+ this.driveGroup = driveGroup;
+ }
+
+ setDeploymentOptions(option: object) {
+ this.selectedOption = option;
+ }
+
+ setDeploymentMode(mode: boolean) {
+ this.simpleDeployment = mode;
+ }
+
+ onNextStep() {
+ if (!this.wizardStepsService.isLastStep()) {
+ this.wizardStepsService.getCurrentStep().subscribe((step: WizardStepModel) => {
+ this.currentStep = step;
+ });
+ this.wizardStepsService.moveToNextStep();
+ } else {
+ this.onSubmit();
+ }
+ }
+
+ onPreviousStep() {
+ if (!this.wizardStepsService.isFirstStep()) {
+ this.wizardStepsService.moveToPreviousStep();
+ } else {
+ this.router.navigate(['/dashboard']);
+ }
+ }
+
+ showSubmitButtonLabel() {
+ return !this.wizardStepsService.isLastStep()
+ ? this.actionLabels.NEXT
+ : $localize`Expand Cluster`;
+ }
+
+ showCancelButtonLabel() {
+ return !this.wizardStepsService.isFirstStep()
+ ? this.actionLabels.BACK
+ : this.actionLabels.CANCEL;
+ }
+
+ ngOnDestroy(): void {
+ this.currentStepSub.unsubscribe();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.html
new file mode 100644
index 000000000..e01d3480e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.html
@@ -0,0 +1,39 @@
+<div class="row">
+ <div class="col-sm-12 col-lg-12">
+ <div class="card">
+ <div class="card-header"
+ i18n>CRUSH map viewer</div>
+ <div class="card-body">
+ <div class="row">
+ <div class="col-sm-6 col-lg-6 tree-container">
+ <i *ngIf="loadingIndicator"
+ [ngClass]="[icons.large, icons.spinner, icons.spin]"></i>
+
+ <tree-root #tree
+ [nodes]="nodes"
+ [options]="treeOptions"
+ (updateData)="onUpdateData()">
+ <ng-template #treeNodeTemplate
+ let-node>
+ <span *ngIf="node.data.status"
+ class="badge"
+ [ngClass]="{'badge-success': ['in', 'up'].includes(node.data.status), 'badge-danger': ['down', 'out', 'destroyed'].includes(node.data.status)}">
+ {{ node.data.status }}
+ </span>
+ <span>&nbsp;</span>
+ <span class="node-name"
+ [ngClass]="{'type-osd': node.data.type === 'osd'}"
+ [innerHTML]="node.data.name"></span>
+ </ng-template>
+ </tree-root>
+ </div>
+ <div class="col-sm-6 col-lg-6 metadata"
+ *ngIf="metadata">
+ <legend>{{ metadataTitle }}</legend>
+ <cd-table-key-value [data]="metadata"></cd-table-key-value>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.scss
new file mode 100644
index 000000000..e581024fd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.scss
@@ -0,0 +1,3 @@
+.tree-container {
+ height: calc(100vh - 200px);
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.spec.ts
new file mode 100644
index 000000000..2fc0c141e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.spec.ts
@@ -0,0 +1,137 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { DebugElement } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { TreeModule } from '@circlon/angular-tree-component';
+import { of } from 'rxjs';
+
+import { CrushRuleService } from '~/app/shared/api/crush-rule.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CrushmapComponent } from './crushmap.component';
+
+describe('CrushmapComponent', () => {
+ let component: CrushmapComponent;
+ let fixture: ComponentFixture<CrushmapComponent>;
+ let debugElement: DebugElement;
+ let crushRuleService: CrushRuleService;
+ let crushRuleServiceInfoSpy: jasmine.Spy;
+ configureTestBed({
+ imports: [HttpClientTestingModule, TreeModule, SharedModule],
+ declarations: [CrushmapComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CrushmapComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ crushRuleService = TestBed.inject(CrushRuleService);
+ crushRuleServiceInfoSpy = spyOn(crushRuleService, 'getInfo');
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should display right title', () => {
+ const span = debugElement.nativeElement.querySelector('.card-header');
+ expect(span.textContent).toBe('CRUSH map viewer');
+ });
+
+ it('should display "No nodes!" if ceph tree nodes is empty array', fakeAsync(() => {
+ crushRuleServiceInfoSpy.and.returnValue(of({ nodes: [] }));
+ fixture.detectChanges();
+ tick(5000);
+ expect(crushRuleService.getInfo).toHaveBeenCalled();
+ expect(component.nodes[0].name).toEqual('No nodes!');
+ component.ngOnDestroy();
+ }));
+
+ it('should have two root nodes', fakeAsync(() => {
+ crushRuleServiceInfoSpy.and.returnValue(
+ of({
+ nodes: [
+ { children: [-2], type: 'root', name: 'default', id: -1 },
+ { children: [1, 0, 2], type: 'host', name: 'my-host', id: -2 },
+ { status: 'up', type: 'osd', name: 'osd.0', id: 0 },
+ { status: 'down', type: 'osd', name: 'osd.1', id: 1 },
+ { status: 'up', type: 'osd', name: 'osd.2', id: 2 },
+ { children: [-4], type: 'datacenter', name: 'site1', id: -3 },
+ { children: [4], type: 'host', name: 'my-host-2', id: -4 },
+ { status: 'up', type: 'osd', name: 'osd.0-2', id: 4 }
+ ],
+ roots: [-1, -3, -6]
+ })
+ );
+ fixture.detectChanges();
+ tick(10000);
+ expect(crushRuleService.getInfo).toHaveBeenCalled();
+ expect(component.nodes).toEqual([
+ {
+ cdId: -3,
+ children: [
+ {
+ children: [
+ {
+ id: component.nodes[0].children[0].children[0].id,
+ cdId: 4,
+ status: 'up',
+ type: 'osd',
+ name: 'osd.0-2 (osd)'
+ }
+ ],
+ id: component.nodes[0].children[0].id,
+ cdId: -4,
+ status: undefined,
+ type: 'host',
+ name: 'my-host-2 (host)'
+ }
+ ],
+ id: component.nodes[0].id,
+ status: undefined,
+ type: 'datacenter',
+ name: 'site1 (datacenter)'
+ },
+ {
+ children: [
+ {
+ children: [
+ {
+ id: component.nodes[1].children[0].children[0].id,
+ cdId: 0,
+ status: 'up',
+ type: 'osd',
+ name: 'osd.0 (osd)'
+ },
+ {
+ id: component.nodes[1].children[0].children[1].id,
+ cdId: 1,
+ status: 'down',
+ type: 'osd',
+ name: 'osd.1 (osd)'
+ },
+ {
+ id: component.nodes[1].children[0].children[2].id,
+ cdId: 2,
+ status: 'up',
+ type: 'osd',
+ name: 'osd.2 (osd)'
+ }
+ ],
+ id: component.nodes[1].children[0].id,
+ cdId: -2,
+ status: undefined,
+ type: 'host',
+ name: 'my-host (host)'
+ }
+ ],
+ id: component.nodes[1].id,
+ cdId: -1,
+ status: undefined,
+ type: 'root',
+ name: 'default (root)'
+ }
+ ]);
+ component.ngOnDestroy();
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.ts
new file mode 100644
index 000000000..e3a9ce578
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/crushmap/crushmap.component.ts
@@ -0,0 +1,122 @@
+import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
+
+import {
+ ITreeOptions,
+ TreeComponent,
+ TreeModel,
+ TreeNode,
+ TREE_ACTIONS
+} from '@circlon/angular-tree-component';
+import { Observable, Subscription } from 'rxjs';
+
+import { CrushRuleService } from '~/app/shared/api/crush-rule.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { TimerService } from '~/app/shared/services/timer.service';
+
+@Component({
+ selector: 'cd-crushmap',
+ templateUrl: './crushmap.component.html',
+ styleUrls: ['./crushmap.component.scss']
+})
+export class CrushmapComponent implements OnDestroy, OnInit {
+ private sub = new Subscription();
+
+ @ViewChild('tree') tree: TreeComponent;
+
+ icons = Icons;
+ loadingIndicator = true;
+ nodes: any[] = [];
+ treeOptions: ITreeOptions = {
+ useVirtualScroll: true,
+ nodeHeight: 22,
+ actionMapping: {
+ mouse: {
+ click: this.onNodeSelected.bind(this)
+ }
+ }
+ };
+
+ metadata: any;
+ metadataTitle: string;
+ metadataKeyMap: { [key: number]: any } = {};
+ data$: Observable<object>;
+
+ constructor(private crushRuleService: CrushRuleService, private timerService: TimerService) {}
+
+ ngOnInit() {
+ this.sub = this.timerService
+ .get(() => this.crushRuleService.getInfo(), 5000)
+ .subscribe((data: any) => {
+ this.loadingIndicator = false;
+ this.nodes = this.abstractTreeData(data);
+ });
+ }
+
+ ngOnDestroy() {
+ this.sub.unsubscribe();
+ }
+
+ private abstractTreeData(data: any): any[] {
+ const nodes = data.nodes || [];
+ const rootNodes = data.roots || [];
+ const treeNodeMap: { [key: number]: any } = {};
+
+ if (0 === nodes.length) {
+ return [
+ {
+ name: 'No nodes!'
+ }
+ ];
+ }
+
+ const roots: any[] = [];
+ nodes.reverse().forEach((node: any) => {
+ if (rootNodes.includes(node.id)) {
+ roots.push(node.id);
+ }
+ treeNodeMap[node.id] = this.generateTreeLeaf(node, treeNodeMap);
+ });
+
+ const children = roots.map((id) => {
+ return treeNodeMap[id];
+ });
+
+ return children;
+ }
+
+ private generateTreeLeaf(node: any, treeNodeMap: any) {
+ const cdId = node.id;
+ this.metadataKeyMap[cdId] = node;
+
+ const name: string = node.name + ' (' + node.type + ')';
+ const status: string = node.status;
+
+ const children: any[] = [];
+ const resultNode = { name, status, cdId, type: node.type };
+ if (node.children) {
+ node.children.sort().forEach((childId: any) => {
+ children.push(treeNodeMap[childId]);
+ });
+
+ resultNode['children'] = children;
+ }
+
+ return resultNode;
+ }
+
+ onNodeSelected(tree: TreeModel, node: TreeNode) {
+ TREE_ACTIONS.ACTIVATE(tree, node, true);
+ if (node.data.cdId !== undefined) {
+ const { name, type, status, ...remain } = this.metadataKeyMap[node.data.cdId];
+ this.metadata = remain;
+ this.metadataTitle = name + ' (' + type + ')';
+ } else {
+ delete this.metadata;
+ delete this.metadataTitle;
+ }
+ }
+
+ onUpdateData() {
+ this.tree.treeModel.expandAll();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json
new file mode 100644
index 000000000..838819790
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json
@@ -0,0 +1,32 @@
+[
+ {
+ "hostname": "ceph-master",
+ "services": [
+ { "type": "mds", "id": "a" },
+ { "type": "mds", "id": "b" },
+ { "type": "mds", "id": "c" },
+ { "type": "mgr", "id": "x" },
+ { "type": "mon", "id": "a" },
+ { "type": "mon", "id": "b" },
+ { "type": "mon", "id": "c" },
+ { "type": "osd", "id": "0" },
+ { "type": "osd", "id": "1" },
+ { "type": "osd", "id": "2" }
+ ],
+ "ceph_version": "ceph version Development (no_version) pacific (dev)",
+ "addr": "",
+ "labels": [],
+ "service_type": "",
+ "sources": { "ceph": true, "orchestrator": false },
+ "status": ""
+ },
+ {
+ "ceph_version": "",
+ "services": [],
+ "sources": { "ceph": false, "orchestrator": true },
+ "hostname": "mgr0",
+ "addr": "mgr0",
+ "labels": [],
+ "status": ""
+ }
+]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html
new file mode 100644
index 000000000..a138768c3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html
@@ -0,0 +1,59 @@
+<ng-container *ngIf="selection">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="host-details">
+ <li ngbNavItem="devices">
+ <a ngbNavLink
+ i18n>Devices</a>
+ <ng-template ngbNavContent>
+ <cd-device-list [hostname]="selection['hostname']"></cd-device-list>
+ </ng-template>
+ </li>
+ <li ngbNavItem="inventory"
+ *ngIf="permissions.hosts.read">
+ <a ngbNavLink
+ i18n>Physical Disks</a>
+ <ng-template ngbNavContent>
+ <cd-inventory [hostname]="selectedHostname"></cd-inventory>
+ </ng-template>
+ </li>
+ <li ngbNavItem="daemons"
+ *ngIf="permissions.hosts.read">
+ <a ngbNavLink
+ i18n>Daemons</a>
+ <ng-template ngbNavContent>
+ <cd-service-daemon-list [hostname]="selectedHostname"
+ flag="hostDetails"
+ [hiddenColumns]="['hostname']">
+ </cd-service-daemon-list>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-details"
+ *ngIf="permissions.grafana.read">
+ <a ngbNavLink
+ i18n>Performance Details</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'host-details?var-ceph_hosts=' + selectedHostname"
+ uid="rtOg0AiWz"
+ grafanaStyle="four">
+ </cd-grafana>
+ </ng-template>
+ </li>
+ <li ngbNavItem="device-health">
+ <a ngbNavLink
+ i18n>Device health</a>
+ <ng-template ngbNavContent>
+ <cd-smart-list *ngIf="selectedHostname; else noHostname"
+ [hostname]="selectedHostname"></cd-smart-list>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
+
+<ng-template #noHostname>
+ <cd-alert-panel type="error"
+ i18n>No hostname found.</cd-alert-panel>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts
new file mode 100644
index 000000000..8d632cc2b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts
@@ -0,0 +1,68 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
+import { CoreModule } from '~/app/core/core.module';
+import { Permissions } from '~/app/shared/models/permissions';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, TabHelper } from '~/testing/unit-test-helper';
+import { HostDetailsComponent } from './host-details.component';
+
+describe('HostDetailsComponent', () => {
+ let component: HostDetailsComponent;
+ let fixture: ComponentFixture<HostDetailsComponent>;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ CephModule,
+ CoreModule,
+ CephSharedModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(HostDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = undefined;
+ component.permissions = new Permissions({
+ hosts: ['read'],
+ grafana: ['read']
+ });
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Host details tabset', () => {
+ beforeEach(() => {
+ component.selection = { hostname: 'localhost' };
+ fixture.detectChanges();
+ });
+
+ it('should recognize a tabset child', () => {
+ const tabsetChild = TabHelper.getNgbNav(fixture);
+ expect(tabsetChild).toBeDefined();
+ });
+
+ it('should show tabs', () => {
+ expect(TabHelper.getTextContents(fixture)).toEqual([
+ 'Devices',
+ 'Physical Disks',
+ 'Daemons',
+ 'Performance Details',
+ 'Device health'
+ ]);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts
new file mode 100644
index 000000000..bc66bdaab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts
@@ -0,0 +1,20 @@
+import { Component, Input } from '@angular/core';
+
+import { Permissions } from '~/app/shared/models/permissions';
+
+@Component({
+ selector: 'cd-host-details',
+ templateUrl: './host-details.component.html',
+ styleUrls: ['./host-details.component.scss']
+})
+export class HostDetailsComponent {
+ @Input()
+ permissions: Permissions;
+
+ @Input()
+ selection: any;
+
+ get selectedHostname(): string {
+ return this.selection !== undefined ? this.selection['hostname'] : null;
+ }
+}
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
new file mode 100644
index 000000000..66fe42f7f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html
@@ -0,0 +1,107 @@
+<cd-modal [pageURL]="pageURL"
+ [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+
+ <ng-container class="modal-content">
+
+ <div *cdFormLoading="loading">
+ <form name="hostForm"
+ #formDir="ngForm"
+ [formGroup]="hostForm"
+ novalidate>
+
+ <div class="modal-body">
+
+ <!-- Hostname -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="hostname">
+ <ng-container i18n>Hostname</ng-container>
+ <cd-helper>
+ <p i18n>To add multiple hosts at once, you can enter:</p>
+ <ul>
+ <li i18n>a comma-separated list of hostnames <samp>(e.g.: example-01,example-02,example-03)</samp>,</li>
+ <li i18n>a range expression <samp>(e.g.: example-[01-03].ceph)</samp>,</li>
+ <li i18n>a comma separated range expression <samp>(e.g.: example-[01-05].lab.com,example2-[1-4].lab.com,example3-[001-006].lab.com)</samp></li>
+ </ul>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="mon-123"
+ id="hostname"
+ name="hostname"
+ formControlName="hostname"
+ autofocus
+ (keyup)="checkHostNameValue()">
+ <span class="invalid-feedback"
+ *ngIf="hostForm.showError('hostname', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="hostForm.showError('hostname', formDir, 'uniqueName')"
+ i18n>The chosen hostname is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Address -->
+ <div class="form-group row"
+ *ngIf="!hostPattern">
+ <label class="cd-col-form-label"
+ for="addr"
+ i18n>Network address</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="192.168.0.1"
+ id="addr"
+ name="addr"
+ formControlName="addr">
+ <span class="invalid-feedback"
+ *ngIf="hostForm.showError('addr', formDir, 'pattern')"
+ i18n>The value is not a valid IP address.</span>
+ </div>
+ </div>
+
+ <!-- Labels -->
+ <div class="form-group row">
+ <label i18n
+ for="labels"
+ class="cd-col-form-label">Labels</label>
+ <div class="cd-col-form-input">
+ <cd-select-badges id="labels"
+ [data]="hostForm.controls.labels.value"
+ [options]="labelsOption"
+ [customBadges]="true"
+ [messages]="messages">
+ </cd-select-badges>
+ </div>
+ </div>
+
+ <!-- Maintenance Mode -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="maintenance"
+ type="checkbox"
+ formControlName="maintenance">
+ <label class="custom-control-label"
+ for="maintenance"
+ i18n>Maintenance Mode</label>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="hostForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </form>
+ </div>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss
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
new file mode 100644
index 000000000..ed85d96cb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts
@@ -0,0 +1,168 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+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';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { HostFormComponent } from './host-form.component';
+
+describe('HostFormComponent', () => {
+ let component: HostFormComponent;
+ let fixture: ComponentFixture<HostFormComponent>;
+ let formHelper: FormHelper;
+
+ configureTestBed(
+ {
+ imports: [
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ReactiveFormsModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [HostFormComponent],
+ providers: [NgbActiveModal]
+ },
+ [LoadingPanelComponent]
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(HostFormComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ formHelper = new FormHelper(component.hostForm);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ 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();
+ formHelper.expectValid('addr');
+ }));
+
+ it('should show error if network address is invalid', fakeAsync(() => {
+ formHelper.setValue('addr', '666.10.10.20', true);
+ tick();
+ formHelper.expectError('addr', 'pattern');
+ }));
+
+ it('should submit the network address', () => {
+ component.hostForm.get('addr').setValue('127.0.0.1');
+ fixture.detectChanges();
+ component.submit();
+ expect(component.addr).toBe('127.0.0.1');
+ });
+
+ it('should validate the labels are added', () => {
+ const labels = ['label1', 'label2'];
+ component.hostForm.get('labels').patchValue(labels);
+ fixture.detectChanges();
+ component.submit();
+ expect(component.allLabels).toBe(labels);
+ });
+
+ it('should select maintenance mode', () => {
+ component.hostForm.get('maintenance').setValue(true);
+ fixture.detectChanges();
+ component.submit();
+ expect(component.status).toBe('maintenance');
+ });
+
+ it('should expand the hostname correctly', () => {
+ component.hostForm.get('hostname').setValue('ceph-node-00.cephlab.com');
+ fixture.detectChanges();
+ component.submit();
+ expect(component.hostnameArray).toStrictEqual(['ceph-node-00.cephlab.com']);
+
+ component.hostnameArray = [];
+
+ component.hostForm.get('hostname').setValue('ceph-node-[00-10].cephlab.com');
+ fixture.detectChanges();
+ component.submit();
+ expect(component.hostnameArray).toStrictEqual([
+ 'ceph-node-00.cephlab.com',
+ 'ceph-node-01.cephlab.com',
+ 'ceph-node-02.cephlab.com',
+ 'ceph-node-03.cephlab.com',
+ 'ceph-node-04.cephlab.com',
+ 'ceph-node-05.cephlab.com',
+ 'ceph-node-06.cephlab.com',
+ 'ceph-node-07.cephlab.com',
+ 'ceph-node-08.cephlab.com',
+ 'ceph-node-09.cephlab.com',
+ 'ceph-node-10.cephlab.com'
+ ]);
+
+ component.hostnameArray = [];
+
+ component.hostForm.get('hostname').setValue('ceph-node-00.cephlab.com,ceph-node-1.cephlab.com');
+ fixture.detectChanges();
+ component.submit();
+ expect(component.hostnameArray).toStrictEqual([
+ 'ceph-node-00.cephlab.com',
+ 'ceph-node-1.cephlab.com'
+ ]);
+
+ component.hostnameArray = [];
+
+ component.hostForm
+ .get('hostname')
+ .setValue('ceph-mon-[01-05].lab.com,ceph-osd-[1-4].lab.com,ceph-rgw-[001-006].lab.com');
+ fixture.detectChanges();
+ component.submit();
+ expect(component.hostnameArray).toStrictEqual([
+ 'ceph-mon-01.lab.com',
+ 'ceph-mon-02.lab.com',
+ 'ceph-mon-03.lab.com',
+ 'ceph-mon-04.lab.com',
+ 'ceph-mon-05.lab.com',
+ 'ceph-osd-1.lab.com',
+ 'ceph-osd-2.lab.com',
+ 'ceph-osd-3.lab.com',
+ 'ceph-osd-4.lab.com',
+ 'ceph-rgw-001.lab.com',
+ 'ceph-rgw-002.lab.com',
+ 'ceph-rgw-003.lab.com',
+ 'ceph-rgw-004.lab.com',
+ 'ceph-rgw-005.lab.com',
+ 'ceph-rgw-006.lab.com'
+ ]);
+
+ component.hostnameArray = [];
+
+ component.hostForm
+ .get('hostname')
+ .setValue('ceph-(mon-[00-04],osd-[001-005],rgw-[1-3]).lab.com');
+ fixture.detectChanges();
+ component.submit();
+ expect(component.hostnameArray).toStrictEqual([
+ 'ceph-mon-00.lab.com',
+ 'ceph-mon-01.lab.com',
+ 'ceph-mon-02.lab.com',
+ 'ceph-mon-03.lab.com',
+ 'ceph-mon-04.lab.com',
+ 'ceph-osd-001.lab.com',
+ 'ceph-osd-002.lab.com',
+ 'ceph-osd-003.lab.com',
+ 'ceph-osd-004.lab.com',
+ 'ceph-osd-005.lab.com',
+ 'ceph-rgw-1.lab.com',
+ 'ceph-rgw-2.lab.com',
+ 'ceph-rgw-3.lab.com'
+ ]);
+ });
+});
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
new file mode 100644
index 000000000..6bfb79d67
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts
@@ -0,0 +1,171 @@
+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 expand from 'brace-expansion';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-host-form',
+ templateUrl: './host-form.component.html',
+ styleUrls: ['./host-form.component.scss']
+})
+export class HostFormComponent extends CdForm implements OnInit {
+ hostForm: CdFormGroup;
+ action: string;
+ resource: string;
+ hostnames: string[];
+ hostnameArray: string[] = [];
+ addr: string;
+ status: string;
+ allLabels: string[];
+ pageURL: string;
+ hostPattern = false;
+ labelsOption: Array<SelectOption> = [];
+
+ messages = new SelectMessages({
+ empty: $localize`There are no labels.`,
+ filter: $localize`Filter or add labels`,
+ add: $localize`Add label`
+ });
+
+ constructor(
+ private router: Router,
+ private actionLabels: ActionLabelsI18n,
+ private hostService: HostService,
+ private taskWrapper: TaskWrapperService,
+ public activeModal: NgbActiveModal
+ ) {
+ super();
+ this.resource = $localize`host`;
+ this.action = this.actionLabels.ADD;
+ }
+
+ ngOnInit() {
+ if (this.router.url.includes('hosts')) {
+ this.pageURL = 'hosts';
+ }
+ this.createForm();
+ this.hostService.list('false').subscribe((resp: any[]) => {
+ this.hostnames = resp.map((host) => {
+ return host['hostname'];
+ });
+ this.loadingReady();
+ });
+
+ this.hostService.getLabels().subscribe((resp: string[]) => {
+ const uniqueLabels = new Set(resp.concat(this.hostService.predefinedLabels));
+ this.labelsOption = Array.from(uniqueLabels).map((label) => {
+ return { enabled: true, name: label, selected: false, description: null };
+ });
+ });
+ }
+
+ // check if hostname is a single value or pattern to hide network address field
+ checkHostNameValue() {
+ const hostNames = this.hostForm.get('hostname').value;
+ hostNames.match(/[()\[\]{},]/g) ? (this.hostPattern = true) : (this.hostPattern = false);
+ }
+
+ private createForm() {
+ this.hostForm = new CdFormGroup({
+ hostname: new FormControl('', {
+ validators: [
+ Validators.required,
+ CdValidators.custom('uniqueName', (hostname: string) => {
+ return this.hostnames && this.hostnames.indexOf(hostname) !== -1;
+ })
+ ]
+ }),
+ addr: new FormControl('', {
+ validators: [CdValidators.ip()]
+ }),
+ labels: new FormControl([]),
+ maintenance: new FormControl({ value: false, disabled: this.pageURL !== 'hosts' })
+ });
+ }
+
+ private isCommaSeparatedPattern(hostname: string) {
+ // eg. ceph-node-01.cephlab.com,ceph-node-02.cephlab.com
+ return hostname.includes(',');
+ }
+
+ private isRangeTypePattern(hostname: string) {
+ // check if it is a range expression or comma separated range expression
+ // eg. ceph-mon-[01-05].lab.com,ceph-osd-[02-08].lab.com,ceph-rgw-[01-09]
+ return hostname.includes('[') && hostname.includes(']') && !hostname.match(/(?![^(]*\)),/g);
+ }
+
+ private replaceBraces(hostname: string) {
+ // pattern to replace range [0-5] to [0..5](valid expression for brace expansion)
+ // replace any kind of brackets with curly braces
+ return hostname
+ .replace(/(\d)\s*-\s*(\d)/g, '$1..$2')
+ .replace(/\(/g, '{')
+ .replace(/\)/g, '}')
+ .replace(/\[/g, '{')
+ .replace(/]/g, '}');
+ }
+
+ // expand hostnames in case hostname is a pattern
+ private checkHostNamePattern(hostname: string) {
+ if (this.isRangeTypePattern(hostname)) {
+ const hostnameRange = this.replaceBraces(hostname);
+ this.hostnameArray = expand(hostnameRange);
+ } else if (this.isCommaSeparatedPattern(hostname)) {
+ let hostArray = [];
+ hostArray = hostname.split(',');
+ hostArray.forEach((host: string) => {
+ if (this.isRangeTypePattern(host)) {
+ const hostnameRange = this.replaceBraces(host);
+ this.hostnameArray = this.hostnameArray.concat(expand(hostnameRange));
+ } else {
+ this.hostnameArray.push(host);
+ }
+ });
+ } else {
+ // single hostname
+ this.hostnameArray.push(hostname);
+ }
+ }
+
+ submit() {
+ const hostname = this.hostForm.get('hostname').value;
+ this.checkHostNamePattern(hostname);
+ this.addr = this.hostForm.get('addr').value;
+ this.status = this.hostForm.get('maintenance').value ? 'maintenance' : '';
+ this.allLabels = this.hostForm.get('labels').value;
+ if (this.pageURL !== 'hosts' && !this.allLabels.includes('_no_schedule')) {
+ this.allLabels.push('_no_schedule');
+ }
+ this.hostnameArray.forEach((hostName: string) => {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('host/' + URLVerbs.ADD, {
+ hostname: hostName
+ }),
+ call: this.hostService.create(hostName, this.addr, this.allLabels, this.status)
+ })
+ .subscribe({
+ error: () => {
+ this.hostForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ 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
new file mode 100644
index 000000000..b41ecfa86
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html
@@ -0,0 +1,77 @@
+<ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem>
+ <a ngbNavLink
+ i18n>Hosts List</a>
+ <ng-template ngbNavContent>
+ <cd-table #table
+ [data]="hosts"
+ [columns]="columns"
+ columnMode="flex"
+ (fetchData)="getHosts($event)"
+ selectionType="single"
+ [hasDetails]="hasTableDetails"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)"
+ [toolHeader]="!hideToolHeader">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions [permission]="permissions.hosts"
+ [selection]="selection"
+ class="btn-group"
+ id="host-actions"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+ <cd-host-details cdTableDetail
+ [permissions]="permissions"
+ [selection]="expandedRow">
+ </cd-host-details>
+ </cd-table>
+ </ng-template>
+ </li>
+ <li ngbNavItem
+ *ngIf="permissions.grafana.read">
+ <a ngbNavLink
+ i18n>Overall Performance</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'host-overview?'"
+ uid="y0KGL0iZz"
+ grafanaStyle="two">
+ </cd-grafana>
+ </ng-template>
+ </li>
+</ul>
+
+<div [ngbNavOutlet]="nav"></div>
+
+<ng-template #servicesTpl
+ let-value="value">
+ <span *ngFor="let instance of value; last as isLast">
+ <span class="badge badge-background-primary" >{{ instance }}</span>
+ <ng-container *ngIf="!isLast">&nbsp;</ng-container>
+ </span>
+</ng-template>
+
+<ng-template #maintenanceConfirmTpl>
+ <div *ngFor="let msg of errorMessage; let last=last">
+ <ul *ngIf="!last || errorMessage.length == '1'">
+ <li i18n>{{ msg }}</li>
+ </ul>
+ </div>
+ <ng-container i18n
+ *ngIf="showSubmit">Are you sure you want to continue?</ng-container>
+</ng-template>
+
+<ng-template #orchTmpl>
+ <span i18n
+ i18n-ngbTooltip
+ ngbTooltip="Data will be available only if Orchestrator is available.">N/A</span>
+</ng-template>
+
+<ng-template #flashTmpl>
+ <span i18n
+ i18n-ngbTooltip
+ ngbTooltip="SSD, NVMEs">Flash</span>
+</ng-template>
+<router-outlet name="modal"></router-outlet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.scss
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
new file mode 100644
index 000000000..5fce54fbb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts
@@ -0,0 +1,419 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
+import { CoreModule } from '~/app/core/core.module';
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import {
+ configureTestBed,
+ OrchestratorHelper,
+ TableActionHelper
+} from '~/testing/unit-test-helper';
+import { HostsComponent } from './hosts.component';
+
+class MockShowForceMaintenanceModal {
+ showModal = false;
+ showModalDialog(msg: string) {
+ if (
+ msg.includes('WARNING') &&
+ !msg.includes('It is NOT safe to stop') &&
+ !msg.includes('ALERT') &&
+ !msg.includes('unsafe to stop')
+ ) {
+ this.showModal = true;
+ }
+ }
+}
+
+describe('HostsComponent', () => {
+ let component: HostsComponent;
+ let fixture: ComponentFixture<HostsComponent>;
+ let hostListSpy: jasmine.Spy;
+ let orchService: OrchestratorService;
+ let showForceMaintenanceModal: MockShowForceMaintenanceModal;
+
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({ hosts: ['read', 'update', 'create', 'delete'] });
+ }
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ CephSharedModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ CephModule,
+ CoreModule
+ ],
+ providers: [
+ { provide: AuthStorageService, useValue: fakeAuthStorageService },
+ TableActionsComponent
+ ]
+ });
+
+ beforeEach(() => {
+ showForceMaintenanceModal = new MockShowForceMaintenanceModal();
+ fixture = TestBed.createComponent(HostsComponent);
+ component = fixture.componentInstance;
+ hostListSpy = spyOn(TestBed.inject(HostService), 'list');
+ orchService = TestBed.inject(OrchestratorService);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render hosts list even with not permission mapped services', () => {
+ const hostname = 'ceph.dev';
+ const payload = [
+ {
+ services: [
+ {
+ type: 'osd',
+ id: '0'
+ },
+ {
+ type: 'rgw',
+ id: 'rgw'
+ },
+ {
+ type: 'notPermissionMappedService',
+ id: '1'
+ }
+ ],
+ hostname: hostname,
+ labels: ['foo', 'bar']
+ }
+ ];
+
+ OrchestratorHelper.mockStatus(false);
+ hostListSpy.and.callFake(() => of(payload));
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ fixture.detectChanges();
+
+ const spans = fixture.debugElement.nativeElement.querySelectorAll(
+ '.datatable-body-cell-label span'
+ );
+ expect(spans[0].textContent).toBe(hostname);
+ });
+
+ it('should show the exact count of the repeating daemons', () => {
+ const hostname = 'ceph.dev';
+ const payload = [
+ {
+ services: [
+ {
+ type: 'mgr',
+ id: 'x'
+ },
+ {
+ type: 'mgr',
+ id: 'y'
+ },
+ {
+ type: 'osd',
+ id: '0'
+ },
+ {
+ type: 'osd',
+ id: '1'
+ },
+ {
+ type: 'osd',
+ id: '2'
+ },
+ {
+ type: 'rgw',
+ id: 'rgw'
+ }
+ ],
+ hostname: hostname,
+ labels: ['foo', 'bar']
+ }
+ ];
+
+ OrchestratorHelper.mockStatus(false);
+ hostListSpy.and.callFake(() => of(payload));
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ fixture.detectChanges();
+
+ const spans = fixture.debugElement.nativeElement.querySelectorAll(
+ '.datatable-body-cell-label span span.badge.badge-background-primary'
+ );
+ expect(spans[0].textContent).toContain('mgr: 2');
+ expect(spans[1].textContent).toContain('osd: 3');
+ expect(spans[2].textContent).toContain('rgw: 1');
+ });
+
+ it('should test if host facts are tranformed correctly if orch available', () => {
+ const features = [OrchestratorFeature.HOST_FACTS];
+ const payload = [
+ {
+ hostname: 'host_test',
+ services: [
+ {
+ type: 'osd',
+ id: '0'
+ }
+ ],
+ cpu_count: 2,
+ cpu_cores: 1,
+ memory_total_kb: 1024,
+ hdd_count: 4,
+ hdd_capacity_bytes: 1024,
+ flash_count: 4,
+ flash_capacity_bytes: 1024,
+ nic_count: 1
+ }
+ ];
+ OrchestratorHelper.mockStatus(true, features);
+ hostListSpy.and.callFake(() => of(payload));
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ expect(hostListSpy).toHaveBeenCalled();
+ expect(component.hosts[0]['cpu_count']).toEqual(2);
+ expect(component.hosts[0]['memory_total_bytes']).toEqual(1048576);
+ expect(component.hosts[0]['raw_capacity']).toEqual(2048);
+ expect(component.hosts[0]['hdd_count']).toEqual(4);
+ expect(component.hosts[0]['flash_count']).toEqual(4);
+ expect(component.hosts[0]['cpu_cores']).toEqual(1);
+ expect(component.hosts[0]['nic_count']).toEqual(1);
+ });
+
+ it('should test if host facts are unavailable if no orch available', () => {
+ const payload = [
+ {
+ hostname: 'host_test',
+ services: [
+ {
+ type: 'osd',
+ id: '0'
+ }
+ ]
+ }
+ ];
+ OrchestratorHelper.mockStatus(false);
+ hostListSpy.and.callFake(() => of(payload));
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ fixture.detectChanges();
+
+ const spans = fixture.debugElement.nativeElement.querySelectorAll(
+ '.datatable-body-cell-label span'
+ );
+ expect(spans[7].textContent).toBe('N/A');
+ });
+
+ it('should test if host facts are unavailable if get_fatcs orch feature is not available', () => {
+ const payload = [
+ {
+ hostname: 'host_test',
+ services: [
+ {
+ type: 'osd',
+ id: '0'
+ }
+ ]
+ }
+ ];
+ OrchestratorHelper.mockStatus(true);
+ hostListSpy.and.callFake(() => of(payload));
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ fixture.detectChanges();
+
+ const spans = fixture.debugElement.nativeElement.querySelectorAll(
+ '.datatable-body-cell-label span'
+ );
+ expect(spans[7].textContent).toBe('N/A');
+ });
+
+ it('should show force maintenance modal when it is safe to stop host', () => {
+ const errorMsg = `WARNING: Stopping 1 out of 1 daemons in Grafana service.
+ Service will not be operational with no daemons left. At
+ least 1 daemon must be running to guarantee service.`;
+ showForceMaintenanceModal.showModalDialog(errorMsg);
+ expect(showForceMaintenanceModal.showModal).toBeTruthy();
+ });
+
+ it('should not show force maintenance modal when error is an ALERT', () => {
+ const errorMsg = `ALERT: Cannot stop active Mgr daemon, Please switch active Mgrs
+ with 'ceph mgr fail ceph-node-00'`;
+ showForceMaintenanceModal.showModalDialog(errorMsg);
+ expect(showForceMaintenanceModal.showModal).toBeFalsy();
+ });
+
+ it('should not show force maintenance modal when it is not safe to stop host', () => {
+ const errorMsg = `WARNING: Stopping 1 out of 1 daemons in Grafana service.
+ Service will not be operational with no daemons left. At
+ least 1 daemon must be running to guarantee service.
+ It is NOT safe to stop ['mon.ceph-node-00']: not enough
+ monitors would be available (ceph-node-02) after stopping mons`;
+ showForceMaintenanceModal.showModalDialog(errorMsg);
+ expect(showForceMaintenanceModal.showModal).toBeFalsy();
+ });
+
+ it('should not show force maintenance modal when it is unsafe to stop host', () => {
+ const errorMsg = 'unsafe to stop osd.0 because of some unknown reason';
+ showForceMaintenanceModal.showModalDialog(errorMsg);
+ expect(showForceMaintenanceModal.showModal).toBeFalsy();
+ });
+
+ describe('table actions', () => {
+ const fakeHosts = require('./fixtures/host_list_response.json');
+
+ beforeEach(() => {
+ hostListSpy.and.callFake(() => of(fakeHosts));
+ });
+
+ const testTableActions = async (
+ orch: boolean,
+ features: OrchestratorFeature[],
+ tests: { selectRow?: number; expectResults: any }[]
+ ) => {
+ OrchestratorHelper.mockStatus(orch, features);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ for (const test of tests) {
+ if (test.selectRow) {
+ component.selection = new CdTableSelection();
+ component.selection.selected = [test.selectRow];
+ }
+ await TableActionHelper.verifyTableActions(
+ fixture,
+ component.tableActions,
+ test.expectResults
+ );
+ }
+ };
+
+ it('should have correct states when Orchestrator is enabled', async () => {
+ const tests = [
+ {
+ expectResults: {
+ Add: { disabled: false, disableDesc: '' },
+ Edit: { disabled: true, disableDesc: '' },
+ Remove: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeHosts[0], // non-orchestrator host
+ expectResults: {
+ Add: { disabled: false, disableDesc: '' },
+ Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
+ Remove: { disabled: true, disableDesc: component.messages.nonOrchHost }
+ }
+ },
+ {
+ selectRow: fakeHosts[1], // orchestrator host
+ expectResults: {
+ Add: { disabled: false, disableDesc: '' },
+ Edit: { disabled: false, disableDesc: '' },
+ Remove: { disabled: false, disableDesc: '' }
+ }
+ }
+ ];
+
+ const features = [
+ OrchestratorFeature.HOST_ADD,
+ OrchestratorFeature.HOST_LABEL_ADD,
+ OrchestratorFeature.HOST_REMOVE,
+ OrchestratorFeature.HOST_LABEL_REMOVE,
+ OrchestratorFeature.HOST_DRAIN
+ ];
+ await testTableActions(true, features, tests);
+ });
+
+ it('should have correct states when Orchestrator is disabled', async () => {
+ const resultNoOrchestrator = {
+ disabled: true,
+ disableDesc: orchService.disableMessages.noOrchestrator
+ };
+ const tests = [
+ {
+ expectResults: {
+ Add: resultNoOrchestrator,
+ Edit: { disabled: true, disableDesc: '' },
+ Remove: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeHosts[0], // non-orchestrator host
+ expectResults: {
+ Add: resultNoOrchestrator,
+ Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
+ Remove: { disabled: true, disableDesc: component.messages.nonOrchHost }
+ }
+ },
+ {
+ selectRow: fakeHosts[1], // orchestrator host
+ expectResults: {
+ Add: resultNoOrchestrator,
+ Edit: resultNoOrchestrator,
+ Remove: resultNoOrchestrator
+ }
+ }
+ ];
+ await testTableActions(false, [], tests);
+ });
+
+ it('should have correct states when Orchestrator features are missing', async () => {
+ const resultMissingFeatures = {
+ disabled: true,
+ disableDesc: orchService.disableMessages.missingFeature
+ };
+ const tests = [
+ {
+ expectResults: {
+ Add: resultMissingFeatures,
+ Edit: { disabled: true, disableDesc: '' },
+ Remove: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeHosts[0], // non-orchestrator host
+ expectResults: {
+ Add: resultMissingFeatures,
+ Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
+ Remove: { disabled: true, disableDesc: component.messages.nonOrchHost }
+ }
+ },
+ {
+ selectRow: fakeHosts[1], // orchestrator host
+ expectResults: {
+ Add: resultMissingFeatures,
+ Edit: resultMissingFeatures,
+ Remove: resultMissingFeatures
+ }
+ }
+ ];
+ await testTableActions(true, [], tests);
+ });
+ });
+});
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
new file mode 100644
index 000000000..4f2279bcf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts
@@ -0,0 +1,535 @@
+import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Observable, Subscription } from 'rxjs';
+import { map, mergeMap } from 'rxjs/operators';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+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, 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';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Daemon } from '~/app/shared/models/daemon.interface';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { Permissions } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+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 { 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';
+
+@Component({
+ selector: 'cd-hosts',
+ templateUrl: './hosts.component.html',
+ styleUrls: ['./hosts.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit {
+ private sub = new Subscription();
+
+ @ViewChild(TableComponent)
+ table: TableComponent;
+ @ViewChild('servicesTpl', { static: true })
+ public servicesTpl: TemplateRef<any>;
+ @ViewChild('maintenanceConfirmTpl', { static: true })
+ maintenanceConfirmTpl: TemplateRef<any>;
+ @ViewChild('orchTmpl', { static: true })
+ orchTmpl: TemplateRef<any>;
+ @ViewChild('flashTmpl', { static: true })
+ flashTmpl: TemplateRef<any>;
+
+ @Input()
+ hiddenColumns: string[] = [];
+
+ @Input()
+ hideTitle = false;
+
+ @Input()
+ hideSubmitBtn = false;
+
+ @Input()
+ hasTableDetails = true;
+
+ @Input()
+ hideToolHeader = false;
+
+ @Input()
+ showGeneralActionsOnly = false;
+
+ permissions: Permissions;
+ columns: Array<CdTableColumn> = [];
+ hosts: Array<object> = [];
+ isLoadingHosts = false;
+ cdParams = { fromLink: '/hosts' };
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ modalRef: NgbModalRef;
+ isExecuting = false;
+ errorMessage: string;
+ enableMaintenanceBtn: boolean;
+ enableDrainBtn: boolean;
+ bsModalRef: NgbModalRef;
+
+ icons = Icons;
+
+ messages = {
+ nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.`
+ };
+
+ orchStatus: OrchestratorStatus;
+ actionOrchFeatures = {
+ add: [OrchestratorFeature.HOST_ADD],
+ edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE],
+ remove: [OrchestratorFeature.HOST_REMOVE],
+ maintenance: [
+ OrchestratorFeature.HOST_MAINTENANCE_ENTER,
+ OrchestratorFeature.HOST_MAINTENANCE_EXIT
+ ],
+ drain: [OrchestratorFeature.HOST_DRAIN]
+ };
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private dimlessBinary: DimlessBinaryPipe,
+ private hostService: HostService,
+ private actionLabels: ActionLabelsI18n,
+ private modalService: ModalService,
+ private taskWrapper: TaskWrapperService,
+ private router: Router,
+ private notificationService: NotificationService,
+ private orchService: OrchestratorService
+ ) {
+ super();
+ this.permissions = this.authStorageService.getPermissions();
+ this.tableActions = [
+ {
+ name: this.actionLabels.ADD,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.router.url.includes('/hosts')
+ ? this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.ADD] } }])
+ : (this.bsModalRef = this.modalService.show(HostFormComponent)),
+ disable: (selection: CdTableSelection) => this.getDisable('add', selection)
+ },
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.editAction(),
+ disable: (selection: CdTableSelection) => this.getDisable('edit', selection)
+ },
+ {
+ name: this.actionLabels.START_DRAIN,
+ permission: 'update',
+ icon: Icons.exit,
+ click: () => this.hostDrain(),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable('drain', selection) || !this.enableDrainBtn,
+ visible: () => !this.showGeneralActionsOnly && this.enableDrainBtn
+ },
+ {
+ name: this.actionLabels.STOP_DRAIN,
+ permission: 'update',
+ icon: Icons.exit,
+ click: () => this.hostDrain(true),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable('drain', selection) || this.enableDrainBtn,
+ visible: () => !this.showGeneralActionsOnly && !this.enableDrainBtn
+ },
+ {
+ name: this.actionLabels.REMOVE,
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteAction(),
+ disable: (selection: CdTableSelection) => this.getDisable('remove', selection)
+ },
+ {
+ name: this.actionLabels.ENTER_MAINTENANCE,
+ permission: 'update',
+ icon: Icons.enter,
+ click: () => this.hostMaintenance(),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable('maintenance', selection) ||
+ this.isExecuting ||
+ this.enableMaintenanceBtn,
+ visible: () => !this.showGeneralActionsOnly && !this.enableMaintenanceBtn
+ },
+ {
+ name: this.actionLabels.EXIT_MAINTENANCE,
+ permission: 'update',
+ icon: Icons.exit,
+ click: () => this.hostMaintenance(),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable('maintenance', selection) ||
+ this.isExecuting ||
+ !this.enableMaintenanceBtn,
+ visible: () => !this.showGeneralActionsOnly && this.enableMaintenanceBtn
+ }
+ ];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Hostname`,
+ prop: 'hostname',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Service Instances`,
+ prop: 'service_instances',
+ flexGrow: 1,
+ cellTemplate: this.servicesTpl
+ },
+ {
+ name: $localize`Labels`,
+ prop: 'labels',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ class: 'badge-dark'
+ }
+ },
+ {
+ name: $localize`Status`,
+ prop: 'status',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ maintenance: { class: 'badge-warning' }
+ }
+ }
+ },
+ {
+ name: $localize`Model`,
+ prop: 'model',
+ flexGrow: 1
+ },
+ {
+ name: $localize`CPUs`,
+ prop: 'cpu_count',
+ flexGrow: 0.3
+ },
+ {
+ name: $localize`Cores`,
+ prop: 'cpu_cores',
+ flexGrow: 0.3
+ },
+ {
+ name: $localize`Total Memory`,
+ prop: 'memory_total_bytes',
+ pipe: this.dimlessBinary,
+ flexGrow: 0.4
+ },
+ {
+ name: $localize`Raw Capacity`,
+ prop: 'raw_capacity',
+ pipe: this.dimlessBinary,
+ flexGrow: 0.5
+ },
+ {
+ name: $localize`HDDs`,
+ prop: 'hdd_count',
+ flexGrow: 0.3
+ },
+ {
+ name: $localize`Flash`,
+ prop: 'flash_count',
+ headerTemplate: this.flashTmpl,
+ flexGrow: 0.3
+ },
+ {
+ name: $localize`NICs`,
+ prop: 'nic_count',
+ flexGrow: 0.3
+ }
+ ];
+
+ this.columns = this.columns.filter((col: any) => {
+ return !this.hiddenColumns.includes(col.prop);
+ });
+ }
+
+ ngOnDestroy() {
+ this.sub.unsubscribe();
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ this.enableMaintenanceBtn = false;
+ this.enableDrainBtn = false;
+ if (this.selection.hasSelection) {
+ if (this.selection.first().status === 'maintenance') {
+ this.enableMaintenanceBtn = true;
+ }
+
+ if (!this.selection.first().labels.includes('_no_schedule')) {
+ this.enableDrainBtn = true;
+ }
+ }
+ }
+
+ editAction() {
+ this.hostService.getLabels().subscribe((resp: string[]) => {
+ const host = this.selection.first();
+ const labels = new Set(resp.concat(this.hostService.predefinedLabels));
+ const allLabels = Array.from(labels).map((label) => {
+ return { enabled: true, name: label };
+ });
+ this.modalService.show(FormModalComponent, {
+ titleText: $localize`Edit Host: ${host.hostname}`,
+ fields: [
+ {
+ type: 'select-badges',
+ name: 'labels',
+ value: host['labels'],
+ label: $localize`Labels`,
+ typeConfig: {
+ customBadges: true,
+ options: allLabels,
+ messages: new SelectMessages({
+ empty: $localize`There are no labels.`,
+ filter: $localize`Filter or add labels`,
+ add: $localize`Add label`
+ })
+ }
+ }
+ ],
+ submitButtonText: $localize`Edit Host`,
+ onSubmit: (values: any) => {
+ this.hostService.update(host['hostname'], true, values.labels).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated Host "${host.hostname}"`
+ );
+ // Reload the data table content.
+ this.table.refreshBtn();
+ });
+ }
+ });
+ });
+ }
+
+ hostMaintenance() {
+ this.isExecuting = true;
+ const host = this.selection.first();
+ if (host['status'] !== 'maintenance') {
+ this.hostService.update(host['hostname'], false, [], true).subscribe(
+ () => {
+ this.isExecuting = false;
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`"${host.hostname}" moved to maintenance`
+ );
+ this.table.refreshBtn();
+ },
+ (error) => {
+ this.isExecuting = false;
+ this.errorMessage = error.error['detail'].split(/\n/);
+ error.preventDefault();
+ if (
+ error.error['detail'].includes('WARNING') &&
+ !error.error['detail'].includes('It is NOT safe to stop') &&
+ !error.error['detail'].includes('ALERT') &&
+ !error.error['detail'].includes('unsafe to stop')
+ ) {
+ const modalVariables = {
+ titleText: $localize`Warning`,
+ buttonText: $localize`Continue`,
+ warning: true,
+ bodyTpl: this.maintenanceConfirmTpl,
+ showSubmit: true,
+ onSubmit: () => {
+ this.hostService.update(host['hostname'], false, [], true, true).subscribe(
+ () => {
+ this.modalRef.close();
+ },
+ () => this.modalRef.close()
+ );
+ }
+ };
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVariables);
+ } else {
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`"${host.hostname}" cannot be put into maintenance`,
+ $localize`${error.error['detail']}`
+ );
+ }
+ }
+ );
+ } else {
+ this.hostService.update(host['hostname'], false, [], true).subscribe(() => {
+ this.isExecuting = false;
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`"${host.hostname}" has exited maintenance`
+ );
+ this.table.refreshBtn();
+ });
+ }
+ }
+
+ hostDrain(stop = false) {
+ const host = this.selection.first();
+ if (stop) {
+ const index = host['labels'].indexOf('_no_schedule', 0);
+ host['labels'].splice(index, 1);
+ this.hostService.update(host['hostname'], true, host['labels']).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`"${host['hostname']}" stopped draining`
+ );
+ this.table.refreshBtn();
+ });
+ } else {
+ this.hostService.update(host['hostname'], false, [], false, false, true).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`"${host['hostname']}" started draining`
+ );
+ this.table.refreshBtn();
+ });
+ }
+ }
+
+ getDisable(
+ action: 'add' | 'edit' | 'remove' | 'maintenance' | 'drain',
+ selection: CdTableSelection
+ ): boolean | string {
+ if (
+ action === 'remove' ||
+ action === 'edit' ||
+ action === 'maintenance' ||
+ action === 'drain'
+ ) {
+ if (!selection?.hasSingleSelection) {
+ return true;
+ }
+ if (!_.every(selection.selected, 'sources.orchestrator')) {
+ return this.messages.nonOrchHost;
+ }
+ }
+ return this.orchService.getTableActionDisableDesc(
+ this.orchStatus,
+ this.actionOrchFeatures[action]
+ );
+ }
+
+ deleteAction() {
+ const hostname = this.selection.first().hostname;
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'Host',
+ itemNames: [hostname],
+ actionDescription: 'remove',
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('host/remove', { hostname: hostname }),
+ call: this.hostService.delete(hostname)
+ })
+ });
+ }
+
+ checkHostsFactsAvailable() {
+ const orchFeatures = this.orchStatus.features;
+ if (!_.isEmpty(orchFeatures)) {
+ if (orchFeatures.get_facts.available) {
+ return true;
+ }
+ return false;
+ }
+ return false;
+ }
+
+ transformHostsData() {
+ if (this.checkHostsFactsAvailable()) {
+ _.forEach(this.hosts, (hostKey) => {
+ hostKey['memory_total_bytes'] = hostKey['memory_total_kb'] * 1024;
+ hostKey['raw_capacity'] = hostKey['hdd_capacity_bytes'] + hostKey['flash_capacity_bytes'];
+ });
+ } else {
+ // mark host facts columns unavailable
+ for (let column = 4; column < this.columns.length; column++) {
+ this.columns[column]['cellTemplate'] = this.orchTmpl;
+ }
+ }
+ }
+
+ getHosts(context: CdTableFetchDataContext) {
+ if (this.isLoadingHosts) {
+ return;
+ }
+ this.isLoadingHosts = true;
+ this.sub = this.orchService
+ .status()
+ .pipe(
+ mergeMap((orchStatus) => {
+ this.orchStatus = orchStatus;
+ const factsAvailable = this.checkHostsFactsAvailable();
+ return this.hostService.list(`${factsAvailable}`);
+ }),
+ map((hostList: object[]) =>
+ hostList.map((host) => {
+ const counts = {};
+ host['service_instances'] = new Set<string>();
+ if (this.orchStatus?.available) {
+ let daemons: Daemon[] = [];
+ let observable: Observable<Daemon[]>;
+ observable = this.hostService.getDaemons(host['hostname']);
+ observable.subscribe((dmns: Daemon[]) => {
+ daemons = dmns;
+ daemons.forEach((daemon: any) => {
+ counts[daemon.daemon_type] = (counts[daemon.daemon_type] || 0) + 1;
+ });
+ daemons.map((daemon: any) => {
+ host['service_instances'].add(
+ `${daemon.daemon_type}: ${counts[daemon.daemon_type]}`
+ );
+ });
+ });
+ } else {
+ host['services'].forEach((service: any) => {
+ counts[service.type] = (counts[service.type] || 0) + 1;
+ });
+ host['services'].map((service: any) => {
+ host['service_instances'].add(`${service.type}: ${counts[service.type]}`);
+ });
+ }
+ return host;
+ })
+ )
+ )
+ .subscribe(
+ (hostList) => {
+ this.hosts = hostList;
+ this.transformHostsData();
+ this.isLoadingHosts = false;
+ },
+ () => {
+ this.isLoadingHosts = false;
+ context.error();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json
new file mode 100644
index 000000000..8a6986a35
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json
@@ -0,0 +1,324 @@
+[
+ {
+ "name": "mgr0",
+ "addr": "mgr0",
+ "devices": [
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sda",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "0",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 10737418240.0,
+ "human_readable_size": "10.00 GB",
+ "path": "/dev/sda",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "ssd",
+ "device_id": "QEMU_HARDDISK_mgr0-1-ssd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sdb",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "0",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 10737418240.0,
+ "human_readable_size": "10.00 GB",
+ "path": "/dev/sdb",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "ssd",
+ "device_id": "QEMU_HARDDISK_mgr0-2-ssd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sdc",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "1",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 21474836480.0,
+ "human_readable_size": "20.00 GB",
+ "path": "/dev/sdc",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "hdd",
+ "device_id": "QEMU_HARDDISK_mgr0-3-hdd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sdd",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "1",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 21474836480.0,
+ "human_readable_size": "20.00 GB",
+ "path": "/dev/sdd",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "hdd",
+ "device_id": "QEMU_HARDDISK_mgr0-4-hdd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": ["locked"],
+ "available": false,
+ "path": "/dev/vda",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "0x1af4",
+ "model": "",
+ "rev": "",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "0",
+ "rotational": "1",
+ "nr_requests": "256",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {
+ "vda1": {
+ "start": "2048",
+ "sectors": "20969472",
+ "sectorsize": 512,
+ "size": 10736369664.0,
+ "human_readable_size": "10.00 GB",
+ "holders": []
+ }
+ },
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 11811160064.0,
+ "human_readable_size": "11.00 GB",
+ "path": "/dev/vda",
+ "locked": 1
+ },
+ "lvs": [],
+ "human_readable_type": "hdd",
+ "device_id": "",
+ "osd_ids": []
+ }
+ ],
+ "labels": []
+ },
+ {
+ "name": "osd0",
+ "addr": "osd0",
+ "devices": [
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sda",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "0",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 10737418240.0,
+ "human_readable_size": "10.00 GB",
+ "path": "/dev/sda",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "ssd",
+ "device_id": "QEMU_HARDDISK_osd0-1-ssd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sdb",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "0",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 10737418240.0,
+ "human_readable_size": "10.00 GB",
+ "path": "/dev/sdb",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "ssd",
+ "device_id": "QEMU_HARDDISK_osd0-2-ssd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sdc",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "1",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 21474836480.0,
+ "human_readable_size": "20.00 GB",
+ "path": "/dev/sdc",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "hdd",
+ "device_id": "QEMU_HARDDISK_osd0-3-hdd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": [],
+ "available": true,
+ "path": "/dev/sdd",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "ATA",
+ "model": "QEMU HARDDISK",
+ "rev": "2.5+",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "512",
+ "rotational": "1",
+ "nr_requests": "64",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {},
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 21474836480.0,
+ "human_readable_size": "20.00 GB",
+ "path": "/dev/sdd",
+ "locked": 0
+ },
+ "lvs": [],
+ "human_readable_type": "hdd",
+ "device_id": "QEMU_HARDDISK_osd0-4-hdd",
+ "osd_ids": []
+ },
+ {
+ "rejected_reasons": ["locked"],
+ "available": false,
+ "path": "/dev/vda",
+ "sys_api": {
+ "removable": "0",
+ "ro": "0",
+ "vendor": "0x1af4",
+ "model": "",
+ "rev": "",
+ "sas_address": "",
+ "sas_device_handle": "",
+ "support_discard": "0",
+ "rotational": "1",
+ "nr_requests": "256",
+ "scheduler_mode": "mq-deadline",
+ "partitions": {
+ "vda1": {
+ "start": "2048",
+ "sectors": "20969472",
+ "sectorsize": 512,
+ "size": 10736369664.0,
+ "human_readable_size": "10.00 GB",
+ "holders": []
+ }
+ },
+ "sectors": 0,
+ "sectorsize": "512",
+ "size": 11811160064.0,
+ "human_readable_size": "11.00 GB",
+ "path": "/dev/vda",
+ "locked": 1
+ },
+ "lvs": [],
+ "human_readable_type": "hdd",
+ "device_id": "",
+ "osd_ids": []
+ }
+ ],
+ "labels": []
+ }
+]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts
new file mode 100644
index 000000000..4af9137de
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts
@@ -0,0 +1,20 @@
+export class SysAPI {
+ vendor: string;
+ model: string;
+ size: number;
+ rotational: string;
+ human_readable_size: string;
+}
+
+export class InventoryDevice {
+ hostname: string;
+ uid: string;
+
+ path: string;
+ sys_api: SysAPI;
+ available: boolean;
+ rejected_reasons: string[];
+ device_id: string;
+ human_readable_type: string;
+ osd_ids: number[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html
new file mode 100644
index 000000000..54cee708d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html
@@ -0,0 +1,16 @@
+<cd-table [data]="devices"
+ [columns]="columns"
+ identifier="uid"
+ [forceIdentifier]="true"
+ [selectionType]="selectionType"
+ columnMode="flex"
+ (fetchData)="getDevices()"
+ [searchField]="false"
+ (updateSelection)="updateSelection($event)"
+ (columnFiltersChanged)="onColumnFiltersChanged($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss
new file mode 100644
index 000000000..e2eb0350c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss
@@ -0,0 +1,12 @@
+.filter {
+ padding-right: 8px;
+}
+
+.fa-stack {
+ font-size: 0.79rem;
+
+ .fa-stack-1x {
+ margin-left: 8px;
+ margin-top: 5px;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts
new file mode 100644
index 000000000..29a3ece96
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts
@@ -0,0 +1,194 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { InventoryDevicesComponent } from './inventory-devices.component';
+
+describe('InventoryDevicesComponent', () => {
+ let component: InventoryDevicesComponent;
+ let fixture: ComponentFixture<InventoryDevicesComponent>;
+ let orchService: OrchestratorService;
+ let hostService: HostService;
+
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({ osd: ['read', 'update', 'create', 'delete'] });
+ }
+ };
+
+ const mockOrchStatus = (available: boolean, features?: OrchestratorFeature[]) => {
+ const orchStatus: OrchestratorStatus = { available: available, message: '', features: {} };
+ if (features) {
+ features.forEach((feature: OrchestratorFeature) => {
+ orchStatus.features[feature] = { available: true };
+ });
+ }
+ component.orchStatus = orchStatus;
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ FormsModule,
+ HttpClientTestingModule,
+ SharedModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [
+ { provide: AuthStorageService, useValue: fakeAuthStorageService },
+ TableActionsComponent
+ ],
+ declarations: [InventoryDevicesComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(InventoryDevicesComponent);
+ component = fixture.componentInstance;
+ hostService = TestBed.inject(HostService);
+ orchService = TestBed.inject(OrchestratorService);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have columns that are sortable', () => {
+ expect(component.columns.every((column) => Boolean(column.prop))).toBeTruthy();
+ });
+
+ it('should call inventoryDataList only when showOnlyAvailableData is true', () => {
+ const hostServiceSpy = spyOn(hostService, 'inventoryDeviceList').and.callThrough();
+ component.getDevices();
+ expect(hostServiceSpy).toBeCalledTimes(0);
+ component.showAvailDeviceOnly = true;
+ component.getDevices();
+ expect(hostServiceSpy).toBeCalledTimes(1);
+ });
+
+ describe('table actions', () => {
+ const fakeDevices = require('./fixtures/inventory_list_response.json');
+
+ beforeEach(() => {
+ component.devices = fakeDevices;
+ component.selectionType = 'single';
+ fixture.detectChanges();
+ });
+
+ const verifyTableActions = async (
+ tableActions: CdTableAction[],
+ expectResult: {
+ [action: string]: { disabled: boolean; disableDesc: string };
+ }
+ ) => {
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
+ // There is actually only one action for now
+ const actions = {};
+ tableActions.forEach((action) => {
+ const actionElement = tableActionElement.query(By.css('button'));
+ actions[action.name] = {
+ disabled: actionElement.classes.disabled,
+ disableDesc: actionElement.properties.title
+ };
+ });
+ expect(actions).toEqual(expectResult);
+ };
+
+ const testTableActions = async (
+ orch: boolean,
+ features: OrchestratorFeature[],
+ tests: { selectRow?: number; expectResults: any }[]
+ ) => {
+ mockOrchStatus(orch, features);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ for (const test of tests) {
+ if (test.selectRow) {
+ component.selection = new CdTableSelection();
+ component.selection.selected = [test.selectRow];
+ }
+ await verifyTableActions(component.tableActions, test.expectResults);
+ }
+ };
+
+ it('should have correct states when Orchestrator is enabled', async () => {
+ const tests = [
+ {
+ expectResults: {
+ Identify: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeDevices[0],
+ expectResults: {
+ Identify: { disabled: false, disableDesc: '' }
+ }
+ }
+ ];
+
+ const features = [OrchestratorFeature.DEVICE_BLINK_LIGHT];
+ await testTableActions(true, features, tests);
+ });
+
+ it('should have correct states when Orchestrator is disabled', async () => {
+ const resultNoOrchestrator = {
+ disabled: true,
+ disableDesc: orchService.disableMessages.noOrchestrator
+ };
+ const tests = [
+ {
+ expectResults: {
+ Identify: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeDevices[0],
+ expectResults: {
+ Identify: resultNoOrchestrator
+ }
+ }
+ ];
+ await testTableActions(false, [], tests);
+ });
+
+ it('should have correct states when Orchestrator features are missing', async () => {
+ const resultMissingFeatures = {
+ disabled: true,
+ disableDesc: orchService.disableMessages.missingFeature
+ };
+ const expectResults = [
+ {
+ expectResults: {
+ Identify: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeDevices[0],
+ expectResults: {
+ Identify: resultMissingFeatures
+ }
+ }
+ ];
+ await testTableActions(true, [], expectResults);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts
new file mode 100644
index 000000000..e0d82cb19
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts
@@ -0,0 +1,254 @@
+import {
+ Component,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ OnInit,
+ Output,
+ ViewChild
+} from '@angular/core';
+
+import _ from 'lodash';
+import { Subscription } from 'rxjs';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
+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';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { Permission } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+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 { InventoryDevice } from './inventory-device.model';
+
+@Component({
+ selector: 'cd-inventory-devices',
+ templateUrl: './inventory-devices.component.html',
+ styleUrls: ['./inventory-devices.component.scss']
+})
+export class InventoryDevicesComponent implements OnInit, OnDestroy {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+
+ // Devices
+ @Input() devices: InventoryDevice[] = [];
+
+ @Input() showAvailDeviceOnly = false;
+ // Do not display these columns
+ @Input() hiddenColumns: string[] = [];
+
+ // Show filters for these columns, specify empty array to disable
+ @Input() filterColumns = [
+ 'hostname',
+ 'human_readable_type',
+ 'available',
+ 'sys_api.vendor',
+ 'sys_api.model',
+ 'sys_api.size'
+ ];
+
+ // Device table row selection type
+ @Input() selectionType: string = undefined;
+
+ @Output() filterChange = new EventEmitter<CdTableColumnFiltersChange>();
+
+ @Output() fetchInventory = new EventEmitter();
+
+ icons = Icons;
+ columns: Array<CdTableColumn> = [];
+ selection: CdTableSelection = new CdTableSelection();
+ permission: Permission;
+ tableActions: CdTableAction[];
+ fetchInventorySub: Subscription;
+
+ @Input() orchStatus: OrchestratorStatus = undefined;
+
+ actionOrchFeatures = {
+ identify: [OrchestratorFeature.DEVICE_BLINK_LIGHT]
+ };
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private dimlessBinary: DimlessBinaryPipe,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ private orchService: OrchestratorService,
+ private hostService: HostService
+ ) {}
+
+ ngOnInit() {
+ this.permission = this.authStorageService.getPermissions().osd;
+ this.tableActions = [
+ {
+ permission: 'update',
+ icon: Icons.show,
+ click: () => this.identifyDevice(),
+ name: $localize`Identify`,
+ disable: (selection: CdTableSelection) => this.getDisable('identify', selection),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection,
+ visible: () => _.isString(this.selectionType)
+ }
+ ];
+ const columns = [
+ {
+ name: $localize`Hostname`,
+ prop: 'hostname',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Device path`,
+ prop: 'path',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Type`,
+ prop: 'human_readable_type',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ hdd: { value: 'HDD', class: 'badge-hdd' },
+ ssd: { value: 'SSD', class: 'badge-ssd' }
+ }
+ }
+ },
+ {
+ name: $localize`Available`,
+ prop: 'available',
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ name: $localize`Vendor`,
+ prop: 'sys_api.vendor',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Model`,
+ prop: 'sys_api.model',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Size`,
+ prop: 'sys_api.size',
+ flexGrow: 1,
+ pipe: this.dimlessBinary
+ },
+ {
+ name: $localize`OSDs`,
+ prop: 'osd_ids',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ class: 'badge-dark',
+ prefix: 'osd.'
+ }
+ }
+ ];
+
+ this.columns = columns.filter((col: any) => {
+ return !this.hiddenColumns.includes(col.prop);
+ });
+
+ // init column filters
+ _.forEach(this.filterColumns, (prop) => {
+ const col = _.find(this.columns, { prop: prop });
+ if (col) {
+ col.filterable = true;
+ }
+ });
+
+ if (this.fetchInventory.observers.length > 0) {
+ this.fetchInventorySub = this.table.fetchData.subscribe(() => {
+ this.fetchInventory.emit();
+ });
+ }
+ }
+
+ getDevices() {
+ if (this.showAvailDeviceOnly) {
+ this.hostService.inventoryDeviceList().subscribe(
+ (devices: InventoryDevice[]) => {
+ this.devices = _.filter(devices, 'available');
+ this.devices = [...this.devices];
+ },
+ () => {
+ this.devices = [];
+ }
+ );
+ } else {
+ this.devices = [...this.devices];
+ }
+ }
+
+ ngOnDestroy() {
+ if (this.fetchInventorySub) {
+ this.fetchInventorySub.unsubscribe();
+ }
+ }
+
+ onColumnFiltersChanged(event: CdTableColumnFiltersChange) {
+ this.filterChange.emit(event);
+ }
+
+ getDisable(action: 'identify', selection: CdTableSelection): boolean | string {
+ if (!selection.hasSingleSelection) {
+ return true;
+ }
+ return this.orchService.getTableActionDisableDesc(
+ this.orchStatus,
+ this.actionOrchFeatures[action]
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ identifyDevice() {
+ const selected = this.selection.first();
+ const hostname = selected.hostname;
+ const device = selected.path || selected.device_id;
+ this.modalService.show(FormModalComponent, {
+ titleText: $localize`Identify device ${device}`,
+ message: $localize`Please enter the duration how long to blink the LED.`,
+ fields: [
+ {
+ type: 'select',
+ name: 'duration',
+ value: 300,
+ required: true,
+ typeConfig: {
+ options: [
+ { text: $localize`1 minute`, value: 60 },
+ { text: $localize`2 minutes`, value: 120 },
+ { text: $localize`5 minutes`, value: 300 },
+ { text: $localize`10 minutes`, value: 600 },
+ { text: $localize`15 minutes`, value: 900 }
+ ]
+ }
+ }
+ ],
+ submitButtonText: $localize`Execute`,
+ onSubmit: (values: any) => {
+ this.hostService.identifyDevice(hostname, device, values.duration).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Identifying '${device}' started on host '${hostname}'`
+ );
+ });
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-host.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-host.model.ts
new file mode 100644
index 000000000..22400113a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-host.model.ts
@@ -0,0 +1,6 @@
+import { InventoryDevice } from './inventory-devices/inventory-device.model';
+
+export class InventoryHost {
+ name: string;
+ devices: InventoryDevice[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html
new file mode 100644
index 000000000..6ba0b7002
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html
@@ -0,0 +1,14 @@
+<cd-orchestrator-doc-panel *ngIf="showDocPanel"></cd-orchestrator-doc-panel>
+<ng-container *ngIf="orchStatus?.available">
+ <legend i18n>Physical Disks</legend>
+ <div class="row">
+ <div class="col-md-12">
+ <cd-inventory-devices [devices]="devices"
+ [hiddenColumns]="hostname === undefined ? [] : ['hostname']"
+ selectionType="single"
+ (fetchInventory)="refresh()"
+ [orchStatus]="orchStatus">
+ </cd-inventory-devices>
+ </div>
+ </div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts
new file mode 100644
index 000000000..dd60f7959
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts
@@ -0,0 +1,67 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { InventoryDevicesComponent } from './inventory-devices/inventory-devices.component';
+import { InventoryComponent } from './inventory.component';
+
+describe('InventoryComponent', () => {
+ let component: InventoryComponent;
+ let fixture: ComponentFixture<InventoryComponent>;
+ let orchService: OrchestratorService;
+ let hostService: HostService;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ FormsModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [InventoryComponent, InventoryDevicesComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(InventoryComponent);
+ component = fixture.componentInstance;
+ orchService = TestBed.inject(OrchestratorService);
+ hostService = TestBed.inject(HostService);
+ spyOn(orchService, 'status').and.returnValue(of({ available: true }));
+ spyOn(hostService, 'inventoryDeviceList').and.callThrough();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should not display doc panel if orchestrator is available', () => {
+ expect(component.showDocPanel).toBeFalsy();
+ });
+
+ describe('after ngOnInit', () => {
+ it('should load devices', () => {
+ fixture.detectChanges();
+ component.refresh(); // click refresh button
+ expect(hostService.inventoryDeviceList).toHaveBeenNthCalledWith(1, undefined, false);
+
+ const newHost = 'host0';
+ component.hostname = newHost;
+ fixture.detectChanges();
+ component.ngOnChanges();
+ expect(hostService.inventoryDeviceList).toHaveBeenNthCalledWith(2, newHost, false);
+ component.refresh(); // click refresh button
+ expect(hostService.inventoryDeviceList).toHaveBeenNthCalledWith(3, newHost, true);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
new file mode 100644
index 000000000..a60f5d698
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
@@ -0,0 +1,90 @@
+import { Component, Input, NgZone, OnChanges, OnDestroy, OnInit } from '@angular/core';
+
+import { Subscription, timer as observableTimer } from 'rxjs';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { InventoryDevice } from './inventory-devices/inventory-device.model';
+
+@Component({
+ selector: 'cd-inventory',
+ templateUrl: './inventory.component.html',
+ styleUrls: ['./inventory.component.scss']
+})
+export class InventoryComponent implements OnChanges, OnInit, OnDestroy {
+ // Display inventory page only for this hostname, ignore to display all.
+ @Input() hostname?: string;
+
+ private reloadSubscriber: Subscription;
+ private reloadInterval = 5000;
+ private firstRefresh = true;
+
+ icons = Icons;
+
+ orchStatus: OrchestratorStatus;
+ showDocPanel = false;
+
+ devices: Array<InventoryDevice> = [];
+
+ constructor(
+ private orchService: OrchestratorService,
+ private hostService: HostService,
+ private ngZone: NgZone
+ ) {}
+
+ ngOnInit() {
+ this.orchService.status().subscribe((status) => {
+ this.orchStatus = status;
+ this.showDocPanel = !status.available;
+ if (status.available) {
+ // Create a timer to get cached inventory from the orchestrator.
+ // Do not ask the orchestrator frequently to refresh its cache data because it's expensive.
+ this.ngZone.runOutsideAngular(() => {
+ // start after first pass because the embedded table calls refresh at init.
+ this.reloadSubscriber = observableTimer(
+ this.reloadInterval,
+ this.reloadInterval
+ ).subscribe(() => {
+ this.ngZone.run(() => {
+ this.getInventory(false);
+ });
+ });
+ });
+ }
+ });
+ }
+
+ ngOnDestroy() {
+ this.reloadSubscriber?.unsubscribe();
+ }
+
+ ngOnChanges() {
+ if (this.orchStatus?.available) {
+ this.devices = [];
+ this.getInventory(false);
+ }
+ }
+
+ getInventory(refresh: boolean) {
+ if (this.hostname === '') {
+ return;
+ }
+ this.hostService.inventoryDeviceList(this.hostname, refresh).subscribe(
+ (devices: InventoryDevice[]) => {
+ this.devices = devices;
+ },
+ () => {
+ this.devices = [];
+ }
+ );
+ }
+
+ refresh() {
+ // Make the first reload (triggered by table) use cached data, and
+ // the remaining reloads (triggered by users) ask orchestrator to refresh inventory.
+ this.getInventory(!this.firstRefresh);
+ this.firstRefresh = false;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.html
new file mode 100644
index 000000000..dd55a678f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.html
@@ -0,0 +1,159 @@
+<div *ngIf="contentData">
+ <ng-container *ngTemplateOutlet="logFiltersTpl"></ng-container>
+
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="logs">
+ <li ngbNavItem="cluster-logs">
+ <a ngbNavLink
+ i18n>Cluster Logs</a>
+ <ng-template ngbNavContent>
+ <div class="card bg-light mb-3"
+ *ngIf="clog">
+ <div class="btn-group"
+ role="group"
+ *ngIf="clog.length">
+ <cd-download-button [objectItem]="clog"
+ [textItem]="clogText"
+ fileName="cluster_log">
+ </cd-download-button>
+ <cd-copy-2-clipboard-button
+ [source]="clogText"
+ [byId]="false">
+ </cd-copy-2-clipboard-button>
+ </div>
+ <div class="card-body">
+ <p *ngFor="let line of clog">
+ <span class="timestamp">{{ line.stamp | cdDate }}</span>
+ <span class="priority {{ line.priority | logPriority }}">{{ line.priority }}</span>
+ <span class="message"
+ [innerHTML]="line.message | searchHighlight: search"></span>
+ </p>
+
+ <ng-container *ngIf="clog.length != 0 else noEntriesTpl"></ng-container>
+ </div>
+ </div>
+ </ng-template>
+ </li>
+ <li ngbNavItem="audit-logs">
+ <a ngbNavLink
+ i18n>Audit Logs</a>
+ <ng-template ngbNavContent>
+ <div class="card bg-light mb-3"
+ *ngIf="audit_log">
+ <div class="btn-group"
+ role="group"
+ *ngIf="audit_log.length">
+ <cd-download-button [objectItem]="audit_log"
+ [textItem]="auditLogText"
+ fileName="audit_log">
+ </cd-download-button>
+ <cd-copy-2-clipboard-button
+ [source]="auditLogText"
+ [byId]="false">
+ </cd-copy-2-clipboard-button>
+ </div>
+ <div class="card-body">
+ <p *ngFor="let line of audit_log">
+ <span class="timestamp">{{ line.stamp | cdDate }}</span>
+ <span class="priority {{ line.priority | logPriority }}">{{ line.priority }}</span>
+ <span class="message"
+ [innerHTML]="line.message | searchHighlight: search"></span>
+ </p>
+
+ <ng-container *ngIf="audit_log.length != 0 else noEntriesTpl"></ng-container>
+ </div>
+ </div>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</div>
+
+<ng-template #logFiltersTpl>
+ <div class="form-inline">
+ <div class="form-group">
+ <label for="logs-priority"
+ i18n>Priority:</label>
+ <select id="logs-priority"
+ class="form-control"
+ [(ngModel)]="priority"
+ (ngModelChange)="filterLogs()">
+ <option *ngFor="let prio of priorities"
+ [value]="prio.value">{{ prio.name }}</option>
+ </select>
+ </div>
+
+ <div class="form-group">
+ <label for="logs-keyword"
+ i18n>Keyword:</label>
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text">
+ <i [ngClass]="[icons.search]"></i>
+ </span>
+ </div>
+
+ <input class="form-control"
+ id="logs-keyword"
+ type="text"
+ [(ngModel)]="search"
+ (keyup)="filterLogs()">
+
+ <div class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ (click)="clearSearchKey()">
+ <i class="icon-prepend {{ icons.destroy }}"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="logs-date"
+ i18n>Date:</label>
+ <div class="input-group">
+ <input class="form-control"
+ id="logs-date"
+ placeholder="YYYY-MM-DD"
+ ngbDatepicker
+ [maxDate]="maxDate"
+ #d="ngbDatepicker"
+ (click)="d.open()"
+ [(ngModel)]="selectedDate"
+ (ngModelChange)="filterLogs()">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ (click)="clearDate()">
+ <i class="icon-prepend {{ icons.destroy }}"></i>
+ </button>
+ </span>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n>Time range:</label>
+ <ngb-timepicker [spinners]="false"
+ [(ngModel)]="startTime"
+ (ngModelChange)="filterLogs()"></ngb-timepicker>
+
+ <span>&nbsp;&mdash;&nbsp;</span>
+
+ <ngb-timepicker [spinners]="false"
+ [(ngModel)]="endTime"
+ (ngModelChange)="filterLogs()"></ngb-timepicker>
+ </div>
+ </div>
+</ng-template>
+
+<ng-template #noEntriesTpl>
+ <span i18n>No log entries found. Please try to select different filter options.</span>
+ <span>&nbsp;</span>
+ <a href="#"
+ (click)="resetFilter()"
+ i18n>Reset filter.</a>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.scss
new file mode 100644
index 000000000..54ab44250
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.scss
@@ -0,0 +1,54 @@
+@use './src/styles/vendor/variables' as vv;
+
+p {
+ font-family: monospace;
+}
+
+.card {
+ .btn-group {
+ margin-top: -45px;
+ position: absolute;
+ right: 0;
+ }
+
+ div p {
+ display: flex;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .timestamp {
+ flex-shrink: 0;
+ font-weight: bold;
+ }
+
+ .priority {
+ margin-left: 0.5rem;
+ }
+
+ .message {
+ margin-left: 1rem;
+ }
+
+ .err {
+ color: vv.$danger;
+ }
+
+ .warn {
+ color: vv.$warning;
+ }
+
+ .info {
+ color: vv.$info;
+ }
+
+ .debug {
+ color: vv.$gray-700;
+ }
+}
+
+::ng-deep cd-logs ngb-timepicker input.ngb-tp-input {
+ width: 3.5rem !important;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.spec.ts
new file mode 100644
index 000000000..69c6051d2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.spec.ts
@@ -0,0 +1,169 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { NgbDatepickerModule, NgbNavModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { LogsService } from '~/app/shared/api/logs.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LogsComponent } from './logs.component';
+
+describe('LogsComponent', () => {
+ let component: LogsComponent;
+ let fixture: ComponentFixture<LogsComponent>;
+ let logsService: LogsService;
+ let logsServiceSpy: jasmine.Spy;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ NgbNavModule,
+ SharedModule,
+ FormsModule,
+ NgbDatepickerModule,
+ NgbTimepickerModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [LogsComponent]
+ });
+
+ beforeEach(() => {
+ logsService = TestBed.inject(LogsService);
+ logsServiceSpy = spyOn(logsService, 'getLogs');
+ logsServiceSpy.and.returnValue(of(null));
+ fixture = TestBed.createComponent(LogsComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('abstractFilters', () => {
+ it('after initialized', () => {
+ const filters = component.abstractFilters();
+ expect(filters.priority).toBe('All');
+ expect(filters.key).toBe('');
+ expect(filters.yearMonthDay).toBe('');
+ expect(filters.sTime).toBe(0);
+ expect(filters.eTime).toBe(1439);
+ });
+ it('change date', () => {
+ component.selectedDate = { year: 2019, month: 1, day: 1 };
+ component.startTime = { hour: 1, minute: 10 };
+ component.endTime = { hour: 12, minute: 10 };
+ const filters = component.abstractFilters();
+ expect(filters.yearMonthDay).toBe('2019-01-01');
+ expect(filters.sTime).toBe(70);
+ expect(filters.eTime).toBe(730);
+ });
+ });
+
+ describe('filterLogs', () => {
+ const contentData: Record<string, any> = {
+ clog: [
+ {
+ name: 'priority',
+ stamp: '2019-02-21 09:39:49.572801',
+ message: 'Manager daemon localhost is now available',
+ priority: '[ERR]'
+ },
+ {
+ name: 'search',
+ stamp: '2019-02-21 09:39:49.572801',
+ message: 'Activating manager daemon localhost',
+ priority: '[INF]'
+ },
+ {
+ name: 'date',
+ stamp: '2019-01-21 09:39:49.572801',
+ message: 'Manager daemon localhost is now available',
+ priority: '[INF]'
+ },
+ {
+ name: 'time',
+ stamp: '2019-02-21 01:39:49.572801',
+ message: 'Manager daemon localhost is now available',
+ priority: '[INF]'
+ }
+ ],
+ audit_log: []
+ };
+ const resetFilter = () => {
+ component.selectedDate = null;
+ component.priority = 'All';
+ component.search = '';
+ component.startTime = { hour: 0, minute: 0 };
+ component.endTime = { hour: 23, minute: 59 };
+ };
+ beforeEach(() => {
+ component.contentData = contentData;
+ });
+
+ it('show all log', () => {
+ component.filterLogs();
+ expect(component.clog.length).toBe(4);
+ });
+
+ it('filter by search key', () => {
+ resetFilter();
+ component.search = 'Activating';
+ component.filterLogs();
+ expect(component.clog.length).toBe(1);
+ expect(component.clog[0].name).toBe('search');
+ });
+
+ it('filter by date', () => {
+ resetFilter();
+ component.selectedDate = { year: 2019, month: 1, day: 21 };
+ component.filterLogs();
+ expect(component.clog.length).toBe(1);
+ expect(component.clog[0].name).toBe('date');
+ });
+
+ it('filter by priority', () => {
+ resetFilter();
+ component.priority = '[ERR]';
+ component.filterLogs();
+ expect(component.clog.length).toBe(1);
+ expect(component.clog[0].name).toBe('priority');
+ });
+
+ it('filter by time range', () => {
+ resetFilter();
+ component.startTime = { hour: 1, minute: 0 };
+ component.endTime = { hour: 2, minute: 0 };
+ component.filterLogs();
+ expect(component.clog.length).toBe(1);
+ expect(component.clog[0].name).toBe('time');
+ });
+ });
+
+ describe('convert logs to text', () => {
+ it('convert cluster & audit logs to text', () => {
+ const logsPayload = {
+ clog: [
+ {
+ name: 'priority',
+ stamp: '2019-02-21 09:39:49.572801',
+ message: 'Manager daemon localhost is now available',
+ priority: '[ERR]'
+ }
+ ],
+ audit_log: [
+ {
+ stamp: '2020-12-22T11:18:13.896920+0000',
+ priority: '[INF]'
+ }
+ ]
+ };
+ logsServiceSpy.and.returnValue(of(logsPayload));
+ fixture.detectChanges();
+ expect(component.clogText).toContain(logsPayload.clog[0].message);
+ expect(component.auditLogText).toContain(logsPayload.audit_log[0].priority);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.ts
new file mode 100644
index 000000000..420da4958
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.ts
@@ -0,0 +1,157 @@
+import { DatePipe } from '@angular/common';
+import { Component, NgZone, OnDestroy, OnInit } from '@angular/core';
+
+import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
+
+import { LogsService } from '~/app/shared/api/logs.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-logs',
+ templateUrl: './logs.component.html',
+ styleUrls: ['./logs.component.scss']
+})
+export class LogsComponent implements OnInit, OnDestroy {
+ contentData: any;
+ clog: Array<any>;
+ audit_log: Array<any>;
+ icons = Icons;
+ clogText: string;
+ auditLogText: string;
+
+ interval: number;
+ priorities: Array<{ name: string; value: string }> = [
+ { name: 'Debug', value: '[DBG]' },
+ { name: 'Info', value: '[INF]' },
+ { name: 'Warning', value: '[WRN]' },
+ { name: 'Error', value: '[ERR]' },
+ { name: 'All', value: 'All' }
+ ];
+ priority = 'All';
+ search = '';
+ selectedDate: NgbDateStruct;
+ startTime = { hour: 0, minute: 0 };
+ endTime = { hour: 23, minute: 59 };
+ maxDate = {
+ year: new Date().getFullYear(),
+ month: new Date().getMonth() + 1,
+ day: new Date().getDate()
+ };
+
+ constructor(
+ private logsService: LogsService,
+ private datePipe: DatePipe,
+ private ngZone: NgZone
+ ) {}
+
+ ngOnInit() {
+ this.getInfo();
+ this.ngZone.runOutsideAngular(() => {
+ this.interval = window.setInterval(() => {
+ this.ngZone.run(() => {
+ this.getInfo();
+ });
+ }, 5000);
+ });
+ }
+
+ ngOnDestroy() {
+ clearInterval(this.interval);
+ }
+
+ getInfo() {
+ this.logsService.getLogs().subscribe((data: any) => {
+ this.contentData = data;
+ this.clogText = this.logToText(this.contentData.clog);
+ this.auditLogText = this.logToText(this.contentData.audit_log);
+ this.filterLogs();
+ });
+ }
+
+ abstractFilters(): any {
+ const priority = this.priority;
+ const key = this.search.toLowerCase();
+ let yearMonthDay: string;
+ if (this.selectedDate) {
+ const m = this.selectedDate.month;
+ const d = this.selectedDate.day;
+
+ const year = this.selectedDate.year;
+ const month = m <= 9 ? `0${m}` : `${m}`;
+ const day = d <= 9 ? `0${d}` : `${d}`;
+ yearMonthDay = `${year}-${month}-${day}`;
+ } else {
+ yearMonthDay = '';
+ }
+
+ const sHour = this.startTime?.hour ?? 0;
+ const sMinutes = this.startTime?.minute ?? 0;
+ const sTime = sHour * 60 + sMinutes;
+
+ const eHour = this.endTime?.hour ?? 23;
+ const eMinutes = this.endTime?.minute ?? 59;
+ const eTime = eHour * 60 + eMinutes;
+
+ return { priority, key, yearMonthDay, sTime, eTime };
+ }
+
+ filterExecutor(logs: Array<any>, filters: any): Array<any> {
+ return logs.filter((line) => {
+ const localDate = this.datePipe.transform(line.stamp, 'mediumTime');
+ const hour = parseInt(localDate.split(':')[0], 10);
+ const minutes = parseInt(localDate.split(':')[1], 10);
+ let prio: string, y_m_d: string, timeSpan: number;
+
+ prio = filters.priority === 'All' ? line.priority : filters.priority;
+ y_m_d = filters.yearMonthDay ? filters.yearMonthDay : line.stamp;
+ timeSpan = hour * 60 + minutes;
+ return (
+ line.priority === prio &&
+ line.message.toLowerCase().indexOf(filters.key) !== -1 &&
+ line.stamp.indexOf(y_m_d) !== -1 &&
+ timeSpan >= filters.sTime &&
+ timeSpan <= filters.eTime
+ );
+ });
+ }
+
+ filterLogs() {
+ const filters = this.abstractFilters();
+ this.clog = this.filterExecutor(this.contentData.clog, filters);
+ this.audit_log = this.filterExecutor(this.contentData.audit_log, filters);
+ }
+
+ clearSearchKey() {
+ this.search = '';
+ this.filterLogs();
+ }
+ clearDate() {
+ this.selectedDate = null;
+ this.filterLogs();
+ }
+ resetFilter() {
+ this.priority = 'All';
+ this.search = '';
+ this.selectedDate = null;
+ this.startTime = { hour: 0, minute: 0 };
+ this.endTime = { hour: 23, minute: 59 };
+ this.filterLogs();
+
+ return false;
+ }
+
+ logToText(log: object) {
+ let logText = '';
+ for (const line of Object.keys(log)) {
+ logText =
+ logText +
+ this.datePipe.transform(log[line].stamp, 'medium') +
+ '\t' +
+ log[line].priority +
+ '\t' +
+ log[line].message +
+ '\n';
+ }
+ return logText;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html
new file mode 100644
index 000000000..29cae36ba
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html
@@ -0,0 +1,4 @@
+<ng-container *ngIf="selection">
+ <cd-table-key-value [data]="module_config">
+ </cd-table-key-value>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts
new file mode 100644
index 000000000..4b3ea971b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts
@@ -0,0 +1,27 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrModuleDetailsComponent } from './mgr-module-details.component';
+
+describe('MgrModuleDetailsComponent', () => {
+ let component: MgrModuleDetailsComponent;
+ let fixture: ComponentFixture<MgrModuleDetailsComponent>;
+
+ configureTestBed({
+ declarations: [MgrModuleDetailsComponent],
+ imports: [HttpClientTestingModule, SharedModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MgrModuleDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = undefined;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts
new file mode 100644
index 000000000..5a08ebedd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts
@@ -0,0 +1,25 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+
+@Component({
+ selector: 'cd-mgr-module-details',
+ templateUrl: './mgr-module-details.component.html',
+ styleUrls: ['./mgr-module-details.component.scss']
+})
+export class MgrModuleDetailsComponent implements OnChanges {
+ module_config: any;
+
+ @Input()
+ selection: any;
+
+ constructor(private mgrModuleService: MgrModuleService) {}
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.mgrModuleService.getConfig(this.selection.name).subscribe((resp: any) => {
+ this.module_config = resp;
+ });
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html
new file mode 100644
index 000000000..b952ce8d8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html
@@ -0,0 +1,110 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="mgrModuleForm"
+ #frm="ngForm"
+ [formGroup]="mgrModuleForm"
+ novalidate>
+ <div class="card">
+ <div class="card-header"
+ i18n>Edit Manager module</div>
+ <div class="card-body">
+ <div class="form-group row"
+ *ngFor="let moduleOption of moduleOptions | keyvalue">
+
+ <!-- Field label -->
+ <label class="cd-col-form-label"
+ for="{{ moduleOption.value.name }}">
+ {{ moduleOption.value.name }}
+ <cd-helper *ngIf="moduleOption.value.long_desc || moduleOption.value.desc">
+ {{ moduleOption.value.long_desc || moduleOption.value.desc | upperFirst }}
+ </cd-helper>
+ </label>
+
+ <!-- Field control -->
+ <!-- bool -->
+ <div class="cd-col-form-input"
+ *ngIf="moduleOption.value.type === 'bool'">
+ <div class="custom-control custom-checkbox">
+ <input id="{{ moduleOption.value.name }}"
+ type="checkbox"
+ class="custom-control-input"
+ formControlName="{{ moduleOption.value.name }}">
+ <label class="custom-control-label"
+ for="{{ moduleOption.value.name }}"></label>
+ </div>
+ </div>
+
+ <!-- addr|str|uuid -->
+ <div class="cd-col-form-input"
+ *ngIf="['addr', 'str', 'uuid'].includes(moduleOption.value.type)">
+ <input id="{{ moduleOption.value.name }}"
+ class="form-control"
+ type="text"
+ formControlName="{{ moduleOption.value.name }}"
+ *ngIf="moduleOption.value.enum_allowed.length === 0">
+ <select id="{{ moduleOption.value.name }}"
+ class="form-control"
+ formControlName="{{ moduleOption.value.name }}"
+ *ngIf="moduleOption.value.enum_allowed.length > 0">
+ <option *ngFor="let value of moduleOption.value.enum_allowed"
+ [ngValue]="value">
+ {{ value }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'invalidUuid')"
+ i18n>The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8</span>
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'pattern')"
+ i18n>The entered value needs to be a valid IP address.</span>
+ </div>
+
+ <!-- uint|int|size|secs -->
+ <div class="cd-col-form-input"
+ *ngIf="['uint', 'int', 'size', 'secs'].includes(moduleOption.value.type)">
+ <input id="{{ moduleOption.value.name }}"
+ class="form-control"
+ type="number"
+ formControlName="{{ moduleOption.value.name }}"
+ min="{{ moduleOption.value.min }}"
+ max="{{ moduleOption.value.max }}">
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'max')"
+ i18n>The entered value is too high! It must be lower or equal to {{ moduleOption.value.max }}.</span>
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'min')"
+ i18n>The entered value is too low! It must be greater or equal to {{ moduleOption.value.min }}.</span>
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ </div>
+
+ <!-- float -->
+ <div class="cd-col-form-input"
+ *ngIf="moduleOption.value.type === 'float'">
+ <input id="{{ moduleOption.value.name }}"
+ class="form-control"
+ type="number"
+ formControlName="{{ moduleOption.value.name }}">
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'pattern')"
+ i18n>The entered value needs to be a number or decimal.</span>
+ </div>
+
+ </div>
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="mgrModuleForm"
+ [submitText]="actionLabels.UPDATE"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts
new file mode 100644
index 000000000..f8c139bc9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts
@@ -0,0 +1,80 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrModuleFormComponent } from './mgr-module-form.component';
+
+describe('MgrModuleFormComponent', () => {
+ let component: MgrModuleFormComponent;
+ let fixture: ComponentFixture<MgrModuleFormComponent>;
+
+ configureTestBed(
+ {
+ declarations: [MgrModuleFormComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ },
+ [LoadingPanelComponent]
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MgrModuleFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('getValidators', () => {
+ it('should return ip validator for type addr', () => {
+ const result = component.getValidators({ type: 'addr' });
+ expect(result.length).toBe(1);
+ });
+
+ it('should return required validator for types uint, int, size, secs', () => {
+ const types = ['uint', 'int', 'size', 'secs'];
+ types.forEach((type) => {
+ const result = component.getValidators({ type: type });
+ expect(result.length).toBe(1);
+ });
+ });
+
+ it('should return required, decimalNumber validators for type float', () => {
+ const result = component.getValidators({ type: 'float' });
+ expect(result.length).toBe(2);
+ });
+
+ it('should return uuid validator for type uuid', () => {
+ const result = component.getValidators({ type: 'uuid' });
+ expect(result.length).toBe(1);
+ });
+
+ it('should return no validator for type str', () => {
+ const result = component.getValidators({ type: 'str' });
+ expect(result.length).toBe(0);
+ });
+
+ it('should return min validator for type str', () => {
+ const result = component.getValidators({ type: 'str', min: 1 });
+ expect(result.length).toBe(1);
+ });
+
+ it('should return min, max validators for type str', () => {
+ const result = component.getValidators({ type: 'str', min: 1, max: 127 });
+ expect(result.length).toBe(2);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts
new file mode 100644
index 000000000..ef44df970
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts
@@ -0,0 +1,135 @@
+import { Component, OnInit } from '@angular/core';
+import { ValidatorFn, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import { forkJoin as observableForkJoin } from 'rxjs';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-mgr-module-form',
+ templateUrl: './mgr-module-form.component.html',
+ styleUrls: ['./mgr-module-form.component.scss']
+})
+export class MgrModuleFormComponent extends CdForm implements OnInit {
+ mgrModuleForm: CdFormGroup;
+ moduleName = '';
+ moduleOptions: any[] = [];
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private route: ActivatedRoute,
+ private router: Router,
+ private formBuilder: CdFormBuilder,
+ private mgrModuleService: MgrModuleService,
+ private notificationService: NotificationService
+ ) {
+ super();
+ }
+
+ ngOnInit() {
+ this.route.params.subscribe((params: { name: string }) => {
+ this.moduleName = decodeURIComponent(params.name);
+ const observables = [
+ this.mgrModuleService.getOptions(this.moduleName),
+ this.mgrModuleService.getConfig(this.moduleName)
+ ];
+ observableForkJoin(observables).subscribe(
+ (resp: object) => {
+ this.moduleOptions = resp[0];
+ // Create the form dynamically.
+ this.createForm();
+ // Set the form field values.
+ this.mgrModuleForm.setValue(resp[1]);
+ this.loadingReady();
+ },
+ (_error) => {
+ this.loadingError();
+ }
+ );
+ });
+ }
+
+ getValidators(moduleOption: any): ValidatorFn[] {
+ const result = [];
+ switch (moduleOption.type) {
+ case 'addr':
+ result.push(CdValidators.ip());
+ break;
+ case 'uint':
+ case 'int':
+ case 'size':
+ case 'secs':
+ result.push(Validators.required);
+ break;
+ case 'str':
+ if (_.isNumber(moduleOption.min)) {
+ result.push(Validators.minLength(moduleOption.min));
+ }
+ if (_.isNumber(moduleOption.max)) {
+ result.push(Validators.maxLength(moduleOption.max));
+ }
+ break;
+ case 'float':
+ result.push(Validators.required);
+ result.push(CdValidators.decimalNumber());
+ break;
+ case 'uuid':
+ result.push(CdValidators.uuid());
+ break;
+ }
+ return result;
+ }
+
+ createForm() {
+ const controlsConfig = {};
+ _.forEach(this.moduleOptions, (moduleOption) => {
+ controlsConfig[moduleOption.name] = [
+ moduleOption.default_value,
+ this.getValidators(moduleOption)
+ ];
+ });
+ this.mgrModuleForm = this.formBuilder.group(controlsConfig);
+ }
+
+ goToListView() {
+ this.router.navigate(['/mgr-modules']);
+ }
+
+ onSubmit() {
+ // Exit immediately if the form isn't dirty.
+ if (this.mgrModuleForm.pristine) {
+ this.goToListView();
+ return;
+ }
+ const config = {};
+ _.forEach(this.moduleOptions, (moduleOption) => {
+ const control = this.mgrModuleForm.get(moduleOption.name);
+ // Append the option only if the value has been modified.
+ if (control.dirty && control.valid) {
+ config[moduleOption.name] = control.value;
+ }
+ });
+ this.mgrModuleService.updateConfig(this.moduleName, config).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated options for module '${this.moduleName}'.`
+ );
+ this.goToListView();
+ },
+ () => {
+ // Reset the 'Submit' button.
+ this.mgrModuleForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html
new file mode 100644
index 000000000..29b287de8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html
@@ -0,0 +1,20 @@
+<cd-table #table
+ [autoReload]="false"
+ [data]="modules"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)"
+ identifier="module"
+ (fetchData)="getModuleList($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-mgr-module-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-mgr-module-details>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts
new file mode 100644
index 000000000..9a0d87d50
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts
@@ -0,0 +1,155 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of as observableOf, throwError as observableThrowError } from 'rxjs';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { MgrModuleDetailsComponent } from '../mgr-module-details/mgr-module-details.component';
+import { MgrModuleListComponent } from './mgr-module-list.component';
+
+describe('MgrModuleListComponent', () => {
+ let component: MgrModuleListComponent;
+ let fixture: ComponentFixture<MgrModuleListComponent>;
+ let mgrModuleService: MgrModuleService;
+ let notificationService: NotificationService;
+
+ configureTestBed({
+ declarations: [MgrModuleListComponent, MgrModuleDetailsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ RouterTestingModule,
+ SharedModule,
+ HttpClientTestingModule,
+ NgbNavModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [MgrModuleService, NotificationService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MgrModuleListComponent);
+ component = fixture.componentInstance;
+ mgrModuleService = TestBed.inject(MgrModuleService);
+ notificationService = TestBed.inject(NotificationService);
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Edit', 'Enable', 'Disable'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ 'create,update': {
+ actions: ['Edit', 'Enable', 'Disable'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ 'create,delete': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ create: { actions: [], primary: { multiple: '', executing: '', single: '', no: '' } },
+ 'update,delete': {
+ actions: ['Edit', 'Enable', 'Disable'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit', 'Enable', 'Disable'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: { actions: [], primary: { multiple: '', executing: '', single: '', no: '' } },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ describe('should update module state', () => {
+ beforeEach(() => {
+ component.selection = new CdTableSelection();
+ spyOn(notificationService, 'suspendToasties');
+ spyOn(component.blockUI, 'start');
+ spyOn(component.blockUI, 'stop');
+ spyOn(component.table, 'refreshBtn');
+ });
+
+ it('should enable module', fakeAsync(() => {
+ spyOn(mgrModuleService, 'enable').and.returnValue(observableThrowError('y'));
+ spyOn(mgrModuleService, 'list').and.returnValues(observableThrowError('z'), observableOf([]));
+ component.selection.add({
+ name: 'foo',
+ enabled: false,
+ always_on: false
+ });
+ component.updateModuleState();
+ tick(2000);
+ tick(2000);
+ expect(mgrModuleService.enable).toHaveBeenCalledWith('foo');
+ expect(mgrModuleService.list).toHaveBeenCalledTimes(2);
+ expect(notificationService.suspendToasties).toHaveBeenCalledTimes(2);
+ expect(component.blockUI.start).toHaveBeenCalled();
+ expect(component.blockUI.stop).toHaveBeenCalled();
+ expect(component.table.refreshBtn).toHaveBeenCalled();
+ }));
+
+ it('should disable module', fakeAsync(() => {
+ spyOn(mgrModuleService, 'disable').and.returnValue(observableThrowError('x'));
+ spyOn(mgrModuleService, 'list').and.returnValue(observableOf([]));
+ component.selection.add({
+ name: 'bar',
+ enabled: true,
+ always_on: false
+ });
+ component.updateModuleState();
+ tick(2000);
+ expect(mgrModuleService.disable).toHaveBeenCalledWith('bar');
+ expect(mgrModuleService.list).toHaveBeenCalledTimes(1);
+ expect(notificationService.suspendToasties).toHaveBeenCalledTimes(2);
+ expect(component.blockUI.start).toHaveBeenCalled();
+ expect(component.blockUI.stop).toHaveBeenCalled();
+ expect(component.table.refreshBtn).toHaveBeenCalled();
+ }));
+
+ it.only('should not disable module without selecting one', () => {
+ expect(component.getTableActionDisabledDesc()).toBeTruthy();
+ });
+
+ it('should not disable dashboard module', () => {
+ component.selection.selected = [
+ {
+ name: 'dashboard'
+ }
+ ];
+ expect(component.getTableActionDisabledDesc()).toBeTruthy();
+ });
+
+ it('should not disable an always-on module', () => {
+ component.selection.selected = [
+ {
+ name: 'bar',
+ always_on: true
+ }
+ ];
+ expect(component.getTableActionDisabledDesc()).toBe('This Manager module is always on.');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts
new file mode 100644
index 000000000..915e54923
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts
@@ -0,0 +1,198 @@
+import { Component, ViewChild } from '@angular/core';
+
+import { BlockUI, NgBlockUI } from 'ng-block-ui';
+import { timer as observableTimer } from 'rxjs';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+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';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-mgr-module-list',
+ templateUrl: './mgr-module-list.component.html',
+ styleUrls: ['./mgr-module-list.component.scss']
+})
+export class MgrModuleListComponent extends ListWithDetails {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+ @BlockUI()
+ blockUI: NgBlockUI;
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ columns: CdTableColumn[] = [];
+ modules: object[] = [];
+ selection: CdTableSelection = new CdTableSelection();
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private mgrModuleService: MgrModuleService,
+ private notificationService: NotificationService
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().configOpt;
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Enabled`,
+ prop: 'enabled',
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ name: $localize`Always-On`,
+ prop: 'always_on',
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ }
+ ];
+ const getModuleUri = () =>
+ this.selection.first() && encodeURIComponent(this.selection.first().name);
+ this.tableActions = [
+ {
+ name: $localize`Edit`,
+ permission: 'update',
+ disable: () => {
+ if (!this.selection.hasSelection) {
+ return true;
+ }
+ // Disable the 'edit' button when the module has no options.
+ return Object.values(this.selection.first().options).length === 0;
+ },
+ routerLink: () => `/mgr-modules/edit/${getModuleUri()}`,
+ icon: Icons.edit
+ },
+ {
+ name: $localize`Enable`,
+ permission: 'update',
+ click: () => this.updateModuleState(),
+ disable: () => this.isTableActionDisabled('enabled'),
+ icon: Icons.start
+ },
+ {
+ name: $localize`Disable`,
+ permission: 'update',
+ click: () => this.updateModuleState(),
+ disable: () => this.getTableActionDisabledDesc(),
+ icon: Icons.stop
+ }
+ ];
+ }
+
+ getModuleList(context: CdTableFetchDataContext) {
+ this.mgrModuleService.list().subscribe(
+ (resp: object[]) => {
+ this.modules = resp;
+ },
+ () => {
+ context.error();
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ /**
+ * Check if the table action is disabled.
+ * @param state The expected module state, e.g. ``enabled`` or ``disabled``.
+ * @returns If the specified state is validated to true or no selection is
+ * done, then ``true`` is returned, otherwise ``false``.
+ */
+ isTableActionDisabled(state: 'enabled' | 'disabled') {
+ if (!this.selection.hasSelection) {
+ return true;
+ }
+ const selected = this.selection.first();
+ // Make sure the user can't modify the run state of the 'Dashboard' module.
+ // This check is only done in the UI because the REST API should still be
+ // able to do so.
+ if (selected.name === 'dashboard') {
+ return true;
+ }
+ // Always-on modules can't be disabled.
+ if (selected.always_on) {
+ return true;
+ }
+ switch (state) {
+ case 'enabled':
+ return selected.enabled;
+ case 'disabled':
+ return !selected.enabled;
+ }
+ }
+
+ getTableActionDisabledDesc(): string | boolean {
+ if (this.selection.first()?.always_on) {
+ return $localize`This Manager module is always on.`;
+ }
+
+ return this.isTableActionDisabled('disabled');
+ }
+
+ /**
+ * Update the Ceph Mgr module state to enabled or disabled.
+ */
+ updateModuleState() {
+ if (!this.selection.hasSelection) {
+ return;
+ }
+
+ let $obs;
+ const fnWaitUntilReconnected = () => {
+ observableTimer(2000).subscribe(() => {
+ // Trigger an API request to check if the connection is
+ // re-established.
+ this.mgrModuleService.list().subscribe(
+ () => {
+ // Resume showing the notification toasties.
+ this.notificationService.suspendToasties(false);
+ // Unblock the whole UI.
+ this.blockUI.stop();
+ // Reload the data table content.
+ this.table.refreshBtn();
+ },
+ () => {
+ fnWaitUntilReconnected();
+ }
+ );
+ });
+ };
+
+ // Note, the Ceph Mgr is always restarted when a module
+ // is enabled/disabled.
+ const module = this.selection.first();
+ if (module.enabled) {
+ $obs = this.mgrModuleService.disable(module.name);
+ } else {
+ $obs = this.mgrModuleService.enable(module.name);
+ }
+ $obs.subscribe(
+ () => undefined,
+ () => {
+ // Suspend showing the notification toasties.
+ this.notificationService.suspendToasties(true);
+ // Block the whole UI to prevent user interactions until
+ // the connection to the backend is reestablished
+ this.blockUI.start($localize`Reconnecting, please wait ...`);
+ fnWaitUntilReconnected();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts
new file mode 100644
index 000000000..9921db6d7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts
@@ -0,0 +1,17 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { AppRoutingModule } from '~/app/app-routing.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { MgrModuleDetailsComponent } from './mgr-module-details/mgr-module-details.component';
+import { MgrModuleFormComponent } from './mgr-module-form/mgr-module-form.component';
+import { MgrModuleListComponent } from './mgr-module-list/mgr-module-list.component';
+
+@NgModule({
+ imports: [AppRoutingModule, CommonModule, ReactiveFormsModule, SharedModule, NgbNavModule],
+ declarations: [MgrModuleListComponent, MgrModuleFormComponent, MgrModuleDetailsComponent]
+})
+export class MgrModulesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html
new file mode 100644
index 000000000..ca9ac8221
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html
@@ -0,0 +1,61 @@
+<div class="row">
+ <div class="col-lg-4">
+ <fieldset>
+ <legend class="cd-header"
+ i18n>Status</legend>
+ <table class="table table-striped"
+ *ngIf="mon_status">
+ <tr>
+ <td i18n
+ class="bold">Cluster ID</td>
+ <td>{{ mon_status.monmap.fsid }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">monmap modified</td>
+ <td>{{ mon_status.monmap.modified | relativeDate }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">monmap epoch</td>
+ <td>{{ mon_status.monmap.epoch }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">quorum con</td>
+ <td>{{ mon_status.features.quorum_con }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">quorum mon</td>
+ <td>{{ mon_status.features.quorum_mon }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">required con</td>
+ <td>{{ mon_status.features.required_con }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">required mon</td>
+ <td>{{ mon_status.features.required_mon }}</td>
+ </tr>
+ </table>
+ </fieldset>
+ </div>
+
+ <div class="col-lg-8">
+ <legend i18n
+ class="in-quorum cd-header">In Quorum</legend>
+ <cd-table [data]="inQuorum.data"
+ [columns]="inQuorum.columns">
+ </cd-table>
+
+ <legend i18n
+ class="in-quorum cd-header">Not In Quorum</legend>
+ <cd-table [data]="notInQuorum.data"
+ (fetchData)="refresh()"
+ [columns]="notInQuorum.columns">
+ </cd-table>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.spec.ts
new file mode 100644
index 000000000..53673c7f4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.spec.ts
@@ -0,0 +1,105 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { of } from 'rxjs';
+
+import { MonitorService } from '~/app/shared/api/monitor.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MonitorComponent } from './monitor.component';
+
+describe('MonitorComponent', () => {
+ let component: MonitorComponent;
+ let fixture: ComponentFixture<MonitorComponent>;
+ let getMonitorSpy: jasmine.Spy;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule],
+ declarations: [MonitorComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [MonitorService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MonitorComponent);
+ component = fixture.componentInstance;
+ const getMonitorPayload: Record<string, any> = {
+ in_quorum: [
+ {
+ stats: { num_sessions: [[1, 5]] }
+ },
+ {
+ stats: {
+ num_sessions: [
+ [1, 1],
+ [2, 10],
+ [3, 1]
+ ]
+ }
+ },
+ {
+ stats: {
+ num_sessions: [
+ [1, 0],
+ [2, 3]
+ ]
+ }
+ },
+ {
+ stats: {
+ num_sessions: [
+ [1, 2],
+ [2, 1],
+ [3, 7],
+ [4, 5]
+ ]
+ }
+ }
+ ],
+ mon_status: null,
+ out_quorum: []
+ };
+ getMonitorSpy = spyOn(TestBed.inject(MonitorService), 'getMonitor').and.returnValue(
+ of(getMonitorPayload)
+ );
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should sort by open sessions column correctly', () => {
+ component.refresh();
+
+ expect(getMonitorSpy).toHaveBeenCalled();
+
+ expect(component.inQuorum.columns[3].comparator(undefined, undefined)).toBe(0);
+ expect(component.inQuorum.columns[3].comparator(null, null)).toBe(0);
+ expect(component.inQuorum.columns[3].comparator([], [])).toBe(0);
+ expect(
+ component.inQuorum.columns[3].comparator(
+ component.inQuorum.data[0].cdOpenSessions,
+ component.inQuorum.data[3].cdOpenSessions
+ )
+ ).toBe(0);
+ expect(
+ component.inQuorum.columns[3].comparator(
+ component.inQuorum.data[0].cdOpenSessions,
+ component.inQuorum.data[1].cdOpenSessions
+ )
+ ).toBe(1);
+ expect(
+ component.inQuorum.columns[3].comparator(
+ component.inQuorum.data[1].cdOpenSessions,
+ component.inQuorum.data[0].cdOpenSessions
+ )
+ ).toBe(-1);
+ expect(
+ component.inQuorum.columns[3].comparator(
+ component.inQuorum.data[2].cdOpenSessions,
+ component.inQuorum.data[1].cdOpenSessions
+ )
+ ).toBe(1);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.ts
new file mode 100644
index 000000000..5ba17e6c5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.ts
@@ -0,0 +1,74 @@
+import { Component } from '@angular/core';
+
+import _ from 'lodash';
+
+import { MonitorService } from '~/app/shared/api/monitor.service';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+
+@Component({
+ selector: 'cd-monitor',
+ templateUrl: './monitor.component.html',
+ styleUrls: ['./monitor.component.scss']
+})
+export class MonitorComponent {
+ mon_status: any;
+ inQuorum: any;
+ notInQuorum: any;
+
+ interval: any;
+
+ constructor(private monitorService: MonitorService) {
+ this.inQuorum = {
+ columns: [
+ { prop: 'name', name: $localize`Name`, cellTransformation: CellTemplate.routerLink },
+ { prop: 'rank', name: $localize`Rank` },
+ { prop: 'public_addr', name: $localize`Public Address` },
+ {
+ prop: 'cdOpenSessions',
+ name: $localize`Open Sessions`,
+ cellTransformation: CellTemplate.sparkline,
+ comparator: (dataA: any, dataB: any) => {
+ // We get the last value of time series to compare:
+ const lastValueA = _.last(dataA);
+ const lastValueB = _.last(dataB);
+
+ if (!lastValueA || !lastValueB || lastValueA === lastValueB) {
+ return 0;
+ }
+
+ return lastValueA > lastValueB ? 1 : -1;
+ }
+ }
+ ]
+ };
+
+ this.notInQuorum = {
+ columns: [
+ { prop: 'name', name: $localize`Name`, cellTransformation: CellTemplate.routerLink },
+ { prop: 'rank', name: $localize`Rank` },
+ { prop: 'public_addr', name: $localize`Public Address` }
+ ]
+ };
+ }
+
+ refresh() {
+ this.monitorService.getMonitor().subscribe((data: any) => {
+ data.in_quorum.map((row: any) => {
+ row.cdOpenSessions = row.stats.num_sessions.map((i: string) => i[1]);
+ row.cdLink = '/perf_counters/mon/' + row.name;
+ row.cdParams = { fromLink: '/monitor' };
+ return row;
+ });
+
+ data.out_quorum.map((row: any) => {
+ row.cdLink = '/perf_counters/mon/' + row.name;
+ row.cdParams = { fromLink: '/monitor' };
+ return row;
+ });
+
+ this.inQuorum.data = [...data.in_quorum];
+ this.notInQuorum.data = [...data.out_quorum];
+ this.mon_status = data.mon_status;
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html
new file mode 100644
index 000000000..9b442dbc7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html
@@ -0,0 +1,20 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>OSD creation preview</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+ <h4 i18n>DriveGroups</h4>
+ <pre>{{ driveGroups | json}}</pre>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="formGroup"
+ [submitText]="action | titlecase"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts
new file mode 100644
index 000000000..cc2db7411
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts
@@ -0,0 +1,38 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdCreationPreviewModalComponent } from './osd-creation-preview-modal.component';
+
+describe('OsdCreationPreviewModalComponent', () => {
+ let component: OsdCreationPreviewModalComponent;
+ let fixture: ComponentFixture<OsdCreationPreviewModalComponent>;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ SharedModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [NgbActiveModal],
+ declarations: [OsdCreationPreviewModalComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdCreationPreviewModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts
new file mode 100644
index 000000000..3e1b0f067
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts
@@ -0,0 +1,62 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-osd-creation-preview-modal',
+ templateUrl: './osd-creation-preview-modal.component.html',
+ styleUrls: ['./osd-creation-preview-modal.component.scss']
+})
+export class OsdCreationPreviewModalComponent {
+ @Input()
+ driveGroups: Object[] = [];
+
+ @Output()
+ submitAction = new EventEmitter();
+
+ action: string;
+ formGroup: CdFormGroup;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private formBuilder: CdFormBuilder,
+ private osdService: OsdService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.action = actionLabels.CREATE;
+ this.createForm();
+ }
+
+ createForm() {
+ this.formGroup = this.formBuilder.group({});
+ }
+
+ onSubmit() {
+ const trackingId = _.join(_.map(this.driveGroups, 'service_id'), ', ');
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('osd/' + URLVerbs.CREATE, {
+ tracking_id: trackingId
+ }),
+ call: this.osdService.create(this.driveGroups, trackingId)
+ })
+ .subscribe({
+ error: () => {
+ this.formGroup.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.submitAction.emit();
+ this.activeModal.close();
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html
new file mode 100644
index 000000000..bd85e2255
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html
@@ -0,0 +1,67 @@
+<ng-container *ngIf="selection">
+ <ul ngbNav
+ #nav="ngbNav"
+ id="tabset-osd-details"
+ class="nav-tabs"
+ cdStatefulTab="osd-details">
+ <li ngbNavItem="devices">
+ <a ngbNavLink
+ i18n>Devices</a>
+ <ng-template ngbNavContent>
+ <cd-device-list [osdId]="osd?.id"></cd-device-list>
+ </ng-template>
+ </li>
+ <li ngbNavItem="attributes">
+ <a ngbNavLink
+ i18n>Attributes (OSD map)</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value [data]="osd?.details?.osd_map">
+ </cd-table-key-value>
+ </ng-template>
+ </li>
+ <li ngbNavItem="metadata">
+ <a ngbNavLink
+ i18n>Metadata</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value *ngIf="osd?.details?.osd_metadata; else noMetaData"
+ (fetchData)="refresh()"
+ [data]="osd?.details?.osd_metadata">
+ </cd-table-key-value>
+ <ng-template #noMetaData>
+ <cd-alert-panel type="warning"
+ i18n>Metadata not available</cd-alert-panel>
+ </ng-template>
+ </ng-template>
+ </li>
+ <li ngbNavItem="device-health">
+ <a ngbNavLink
+ i18n>Device health</a>
+ <ng-template ngbNavContent>
+ <cd-smart-list [osdId]="osd?.id"></cd-smart-list>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-counter">
+ <a ngbNavLink
+ i18n>Performance counter</a>
+ <ng-template ngbNavContent>
+ <cd-table-performance-counter *ngIf="osd?.details"
+ serviceType="osd"
+ [serviceId]="osd?.id">
+ </cd-table-performance-counter>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-details"
+ *ngIf="grafanaPermission.read">
+ <a ngbNavLink
+ i18n>Performance Details</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'osd-device-details?var-osd=osd.' + osd['id']"
+ uid="CrAHE0iZz"
+ grafanaStyle="three">
+ </cd-grafana>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts
new file mode 100644
index 000000000..ebb1ef044
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts
@@ -0,0 +1,31 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { TablePerformanceCounterComponent } from '~/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component';
+import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdDetailsComponent } from './osd-details.component';
+
+describe('OsdDetailsComponent', () => {
+ let component: OsdDetailsComponent;
+ let fixture: ComponentFixture<OsdDetailsComponent>;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, NgbNavModule, SharedModule, CephSharedModule],
+ declarations: [OsdDetailsComponent, TablePerformanceCounterComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = undefined;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.ts
new file mode 100644
index 000000000..5e52880f0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.ts
@@ -0,0 +1,44 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import _ from 'lodash';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-osd-details',
+ templateUrl: './osd-details.component.html',
+ styleUrls: ['./osd-details.component.scss']
+})
+export class OsdDetailsComponent implements OnChanges {
+ @Input()
+ selection: any;
+
+ osd: {
+ id?: number;
+ details?: any;
+ tree?: any;
+ };
+ grafanaPermission: Permission;
+
+ constructor(private osdService: OsdService, private authStorageService: AuthStorageService) {
+ this.grafanaPermission = this.authStorageService.getPermissions().grafana;
+ }
+
+ ngOnChanges() {
+ if (this.osd?.id !== this.selection?.id) {
+ this.osd = this.selection;
+ }
+
+ if (_.isNumber(this.osd?.id)) {
+ this.refresh();
+ }
+ }
+
+ refresh() {
+ this.osdService.getDetails(this.osd.id).subscribe((data) => {
+ this.osd.details = data;
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts
new file mode 100644
index 000000000..0467f14f6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts
@@ -0,0 +1,5 @@
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+
+export interface DevicesSelectionChangeEvent extends CdTableColumnFiltersChange {
+ type: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts
new file mode 100644
index 000000000..4e7a2ff54
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts
@@ -0,0 +1,6 @@
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+
+export interface DevicesSelectionClearEvent {
+ type: string;
+ clearedDevices: InventoryDevice[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html
new file mode 100644
index 000000000..8b1f59eac
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html
@@ -0,0 +1,51 @@
+<!-- button -->
+<div class="form-group row">
+ <label class="cd-col-form-label"
+ for="createDeleteButton">
+ <ng-container i18n>{{ name }} devices</ng-container>
+ <cd-helper>
+ <span i18n
+ *ngIf="type === 'data'">The primary storage devices. These devices contain all OSD data.</span>
+ <span i18n
+ *ngIf="type === 'wal'">Write-Ahead-Log devices. These devices are used for BlueStore’s internal journal. It is only useful to use a WAL device if the device is faster than the primary device (e.g. NVME devices or SSDs). If there is only a small amount of fast storage available (e.g., less than a gigabyte), we recommend using it as a WAL device.</span>
+ <span i18n
+ *ngIf="type === 'db'">DB devices can be used for storing BlueStore’s internal metadata. It is only helpful to provision a DB device if it is faster than the primary device (e.g. NVME devices or SSD).</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <ng-container *ngIf="devices.length === 0; else blockClearDevices">
+ <button type="button"
+ class="btn btn-light"
+ (click)="showSelectionModal()"
+ data-toggle="tooltip"
+ [title]="addButtonTooltip"
+ [disabled]="availDevices.length === 0 || !canSelect || expansionCanSelect">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add</ng-container>
+ </button>
+ </ng-container>
+ <ng-template #blockClearDevices>
+ <div class="pb-2 my-2 border-bottom">
+ <span *ngFor="let filter of appliedFilters">
+ <span class="badge badge-dark mr-2">{{ filter.name }}: {{ filter.value.formatted }}</span>
+ </span>
+ <a class="tc_clearSelections"
+ href=""
+ (click)="clearDevices(); false">
+ <i [ngClass]="[icons.clearFilters]"></i>
+ <ng-container i18n>Clear</ng-container>
+ </a>
+ </div>
+ <div>
+ <cd-inventory-devices [devices]="devices"
+ [hiddenColumns]="['available', 'osd_ids']"
+ [filterColumns]="[]">
+ </cd-inventory-devices>
+ </div>
+ <div *ngIf="type === 'data'"
+ class="float-right">
+ <span i18n>Raw capacity: {{ capacity | dimlessBinary }}</span>
+ </div>
+ </ng-template>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss
new file mode 100644
index 000000000..3fb8f6b38
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss
@@ -0,0 +1,3 @@
+.tc_clearSelections {
+ text-decoration: none;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts
new file mode 100644
index 000000000..dea6746cf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts
@@ -0,0 +1,125 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { InventoryDevicesComponent } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FixtureHelper, Mocks } from '~/testing/unit-test-helper';
+import { OsdDevicesSelectionGroupsComponent } from './osd-devices-selection-groups.component';
+
+describe('OsdDevicesSelectionGroupsComponent', () => {
+ let component: OsdDevicesSelectionGroupsComponent;
+ let fixture: ComponentFixture<OsdDevicesSelectionGroupsComponent>;
+ let fixtureHelper: FixtureHelper;
+ const devices: InventoryDevice[] = [Mocks.getInventoryDevice('node0', '1')];
+
+ const buttonSelector = '.cd-col-form-input button';
+ const getButton = () => {
+ const debugElement = fixtureHelper.getElementByCss(buttonSelector);
+ return debugElement.nativeElement;
+ };
+ const clearTextSelector = '.tc_clearSelections';
+ const getClearText = () => {
+ const debugElement = fixtureHelper.getElementByCss(clearTextSelector);
+ return debugElement.nativeElement;
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ FormsModule,
+ HttpClientTestingModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule
+ ],
+ declarations: [OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdDevicesSelectionGroupsComponent);
+ fixtureHelper = new FixtureHelper(fixture);
+ component = fixture.componentInstance;
+ component.canSelect = true;
+ });
+
+ describe('without available devices', () => {
+ beforeEach(() => {
+ component.availDevices = [];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should display Add button in disabled state', () => {
+ const button = getButton();
+ expect(button).toBeTruthy();
+ expect(button.disabled).toBe(true);
+ expect(button.textContent).toBe('Add');
+ });
+
+ it('should not display devices table', () => {
+ fixtureHelper.expectElementVisible('cd-inventory-devices', false);
+ });
+ });
+
+ describe('without devices selected', () => {
+ beforeEach(() => {
+ component.availDevices = devices;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should display Add button in enabled state', () => {
+ const button = getButton();
+ expect(button).toBeTruthy();
+ expect(button.disabled).toBe(false);
+ expect(button.textContent).toBe('Add');
+ });
+
+ it('should not display devices table', () => {
+ fixtureHelper.expectElementVisible('cd-inventory-devices', false);
+ });
+ });
+
+ describe('with devices selected', () => {
+ beforeEach(() => {
+ component.isOsdPage = true;
+ component.availDevices = [];
+ component.devices = devices;
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should display clear link', () => {
+ const text = getClearText();
+ expect(text).toBeTruthy();
+ expect(text.textContent).toBe('Clear');
+ });
+
+ it('should display devices table', () => {
+ fixtureHelper.expectElementVisible('cd-inventory-devices', true);
+ });
+
+ it('should clear devices by clicking Clear link', () => {
+ spyOn(component.cleared, 'emit');
+ fixtureHelper.clickElement(clearTextSelector);
+ fixtureHelper.expectElementVisible('cd-inventory-devices', false);
+ const event: Record<string, any> = {
+ type: undefined,
+ clearedDevices: devices
+ };
+ expect(component.cleared.emit).toHaveBeenCalledWith(event);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts
new file mode 100644
index 000000000..cff0cbc05
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts
@@ -0,0 +1,135 @@
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
+import { Router } from '@angular/router';
+
+import _ from 'lodash';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { OsdDevicesSelectionModalComponent } from '../osd-devices-selection-modal/osd-devices-selection-modal.component';
+import { DevicesSelectionChangeEvent } from './devices-selection-change-event.interface';
+import { DevicesSelectionClearEvent } from './devices-selection-clear-event.interface';
+
+@Component({
+ selector: 'cd-osd-devices-selection-groups',
+ templateUrl: './osd-devices-selection-groups.component.html',
+ styleUrls: ['./osd-devices-selection-groups.component.scss']
+})
+export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges {
+ // data, wal, db
+ @Input() type: string;
+
+ // Data, WAL, DB
+ @Input() name: string;
+
+ @Input() hostname: string;
+
+ @Input() availDevices: InventoryDevice[];
+
+ @Input() canSelect: boolean;
+
+ @Output()
+ selected = new EventEmitter<DevicesSelectionChangeEvent>();
+
+ @Output()
+ cleared = new EventEmitter<DevicesSelectionClearEvent>();
+
+ icons = Icons;
+ devices: InventoryDevice[] = [];
+ capacity = 0;
+ appliedFilters = new Array();
+ expansionCanSelect = false;
+ isOsdPage: boolean;
+
+ addButtonTooltip: String;
+ tooltips = {
+ noAvailDevices: $localize`No available devices`,
+ addPrimaryFirst: $localize`Please add primary devices first`,
+ addByFilters: $localize`Add devices by using filters`
+ };
+
+ constructor(
+ private modalService: ModalService,
+ public osdService: OsdService,
+ private router: Router
+ ) {
+ this.isOsdPage = this.router.url.includes('/osd');
+ }
+
+ ngOnInit() {
+ if (!this.isOsdPage) {
+ this.osdService?.osdDevices[this.type]
+ ? (this.devices = this.osdService.osdDevices[this.type])
+ : (this.devices = []);
+ this.capacity = _.sumBy(this.devices, 'sys_api.size');
+ this.osdService?.osdDevices
+ ? (this.expansionCanSelect = this.osdService?.osdDevices['disableSelect'])
+ : (this.expansionCanSelect = false);
+ }
+ this.updateAddButtonTooltip();
+ }
+
+ ngOnChanges() {
+ this.updateAddButtonTooltip();
+ }
+
+ showSelectionModal() {
+ let filterColumns = ['human_readable_type', 'sys_api.vendor', 'sys_api.model', 'sys_api.size'];
+ if (this.type === 'data') {
+ filterColumns = ['hostname', ...filterColumns];
+ }
+ const initialState = {
+ hostname: this.hostname,
+ deviceType: this.name,
+ devices: this.availDevices,
+ filterColumns: filterColumns
+ };
+ const modalRef = this.modalService.show(OsdDevicesSelectionModalComponent, initialState, {
+ size: 'xl'
+ });
+ modalRef.componentInstance.submitAction.subscribe((result: CdTableColumnFiltersChange) => {
+ this.devices = result.data;
+ this.capacity = _.sumBy(this.devices, 'sys_api.size');
+ this.appliedFilters = result.filters;
+ const event = _.assign({ type: this.type }, result);
+ if (!this.isOsdPage) {
+ this.osdService.osdDevices[this.type] = this.devices;
+ this.osdService.osdDevices['disableSelect'] =
+ this.canSelect || this.devices.length === this.availDevices.length;
+ this.osdService.osdDevices[this.type]['capacity'] = this.capacity;
+ }
+ this.selected.emit(event);
+ });
+ }
+
+ private updateAddButtonTooltip() {
+ if (this.type === 'data' && this.availDevices.length === 0) {
+ this.addButtonTooltip = this.tooltips.noAvailDevices;
+ } else {
+ if (!this.canSelect) {
+ // No primary devices added yet.
+ this.addButtonTooltip = this.tooltips.addPrimaryFirst;
+ } else if (this.availDevices.length === 0) {
+ this.addButtonTooltip = this.tooltips.noAvailDevices;
+ } else {
+ this.addButtonTooltip = this.tooltips.addByFilters;
+ }
+ }
+ }
+
+ clearDevices() {
+ if (!this.isOsdPage) {
+ this.expansionCanSelect = false;
+ this.osdService.osdDevices['disableSelect'] = false;
+ this.osdService.osdDevices = [];
+ }
+ const event = {
+ type: this.type,
+ clearedDevices: [...this.devices]
+ };
+ this.devices = [];
+ this.cleared.emit(event);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html
new file mode 100644
index 000000000..3e53d5c41
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html
@@ -0,0 +1,42 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>{{ deviceType }} devices</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+ <cd-alert-panel *ngIf="!canSubmit"
+ type="warning"
+ size="slim"
+ [showTitle]="false">
+ <ng-container i18n>At least one of these filters must be applied in order to proceed:</ng-container>
+ <span *ngFor="let filter of requiredFilters"
+ class="badge badge-dark ml-2">
+ {{ filter }}
+ </span>
+ </cd-alert-panel>
+ <cd-inventory-devices #inventoryDevices
+ [devices]="devices"
+ [filterColumns]="filterColumns"
+ [showAvailDeviceOnly]="true"
+ [hiddenColumns]="['available', 'osd_ids']"
+ (filterChange)="onFilterChange($event)">
+ </cd-inventory-devices>
+ <div *ngIf="canSubmit">
+ <p class="text-center">
+ <span i18n>Number of devices: {{ filteredDevices.length }}. Raw capacity:
+ {{ capacity | dimlessBinary }}.</span>
+ </p>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="formGroup"
+ [disabled]="!canSubmit || filteredDevices.length === 0"
+ [submitText]="action | titlecase"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts
new file mode 100644
index 000000000..60ef65d05
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts
@@ -0,0 +1,109 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { InventoryDevicesComponent } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component';
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, Mocks } from '~/testing/unit-test-helper';
+import { OsdDevicesSelectionModalComponent } from './osd-devices-selection-modal.component';
+
+describe('OsdDevicesSelectionModalComponent', () => {
+ let component: OsdDevicesSelectionModalComponent;
+ let fixture: ComponentFixture<OsdDevicesSelectionModalComponent>;
+ let timeoutFn: Function;
+
+ const devices: InventoryDevice[] = [Mocks.getInventoryDevice('node0', '1')];
+
+ const expectSubmitButton = (enabled: boolean) => {
+ const nativeElement = fixture.debugElement.nativeElement;
+ const button = nativeElement.querySelector('.modal-footer .tc_submitButton');
+ expect(button.disabled).toBe(!enabled);
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ FormsModule,
+ HttpClientTestingModule,
+ SharedModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [NgbActiveModal],
+ declarations: [OsdDevicesSelectionModalComponent, InventoryDevicesComponent]
+ });
+
+ beforeEach(() => {
+ spyOn(window, 'setTimeout').and.callFake((fn) => (timeoutFn = fn));
+
+ fixture = TestBed.createComponent(OsdDevicesSelectionModalComponent);
+ component = fixture.componentInstance;
+ component.devices = devices;
+
+ // Mocks InventoryDeviceComponent
+ component.inventoryDevices = {
+ columns: [
+ { name: 'Device path', prop: 'path' },
+ {
+ name: 'Type',
+ prop: 'human_readable_type'
+ },
+ {
+ name: 'Available',
+ prop: 'available'
+ }
+ ]
+ } as InventoryDevicesComponent;
+ // Mocks the update from the above component
+ component.filterColumns = ['path', 'human_readable_type'];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should disable submit button initially', () => {
+ expectSubmitButton(false);
+ });
+
+ it(
+ 'should update requiredFilters after ngAfterViewInit is called to prevent ' +
+ 'ExpressionChangedAfterItHasBeenCheckedError',
+ () => {
+ expect(component.requiredFilters).toEqual([]);
+ timeoutFn();
+ expect(component.requiredFilters).toEqual(['Device path', 'Type']);
+ }
+ );
+
+ it('should enable submit button after filtering some devices', () => {
+ const event: CdTableColumnFiltersChange = {
+ filters: [
+ {
+ name: 'hostname',
+ prop: 'hostname',
+ value: { raw: 'node0', formatted: 'node0' }
+ },
+ {
+ name: 'size',
+ prop: 'size',
+ value: { raw: '1024', formatted: '1KiB' }
+ }
+ ],
+ data: devices,
+ dataOut: []
+ };
+ component.onFilterChange(event);
+ fixture.detectChanges();
+ expectSubmitButton(true);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts
new file mode 100644
index 000000000..edfe9d6a7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts
@@ -0,0 +1,92 @@
+import { AfterViewInit, Component, EventEmitter, Output, ViewChild } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { TableColumnProp } from '@swimlane/ngx-datatable';
+import _ from 'lodash';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { InventoryDevicesComponent } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
+
+@Component({
+ selector: 'cd-osd-devices-selection-modal',
+ templateUrl: './osd-devices-selection-modal.component.html',
+ styleUrls: ['./osd-devices-selection-modal.component.scss']
+})
+export class OsdDevicesSelectionModalComponent implements AfterViewInit {
+ @ViewChild('inventoryDevices')
+ inventoryDevices: InventoryDevicesComponent;
+
+ @Output()
+ submitAction = new EventEmitter<CdTableColumnFiltersChange>();
+
+ icons = Icons;
+ filterColumns: TableColumnProp[] = [];
+
+ hostname: string;
+ deviceType: string;
+ formGroup: CdFormGroup;
+ action: string;
+
+ devices: InventoryDevice[] = [];
+ filteredDevices: InventoryDevice[] = [];
+ capacity = 0;
+ event: CdTableColumnFiltersChange;
+ canSubmit = false;
+ requiredFilters: string[] = [];
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ public wizardStepService: WizardStepsService
+ ) {
+ this.action = actionLabels.ADD;
+ this.createForm();
+ }
+
+ ngAfterViewInit() {
+ // At least one filter other than hostname is required
+ // Extract the name from table columns for i18n strings
+ const cols = _.filter(this.inventoryDevices.columns, (col) => {
+ return this.filterColumns.includes(col.prop) && col.prop !== 'hostname';
+ });
+ // Fixes 'ExpressionChangedAfterItHasBeenCheckedError'
+ setTimeout(() => {
+ this.requiredFilters = _.map(cols, 'name');
+ }, 0);
+ }
+
+ createForm() {
+ this.formGroup = this.formBuilder.group({});
+ }
+
+ onFilterChange(event: CdTableColumnFiltersChange) {
+ this.capacity = 0;
+ this.canSubmit = false;
+ if (_.isEmpty(event.filters)) {
+ // filters are cleared
+ this.filteredDevices = [];
+ this.event = undefined;
+ } else {
+ // at least one filter is required (except hostname)
+ const filters = event.filters.filter((filter) => {
+ return filter.prop !== 'hostname';
+ });
+ this.canSubmit = !_.isEmpty(filters);
+ this.filteredDevices = event.data;
+ this.capacity = _.sumBy(this.filteredDevices, 'sys_api.size');
+ this.event = event;
+ }
+ }
+
+ onSubmit() {
+ this.submitAction.emit(this.event);
+ this.activeModal.close();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html
new file mode 100644
index 000000000..f8a10ff24
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.html
@@ -0,0 +1,48 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>Individual OSD Flags</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="osdFlagsForm"
+ #formDir="ngForm"
+ [formGroup]="osdFlagsForm"
+ novalidate>
+ <div class="modal-body osd-modal">
+ <div class="custom-control custom-checkbox"
+ *ngFor="let flag of flags; let last = last">
+ <input class="custom-control-input"
+ type="checkbox"
+ [checked]="flag.value"
+ [indeterminate]="flag.indeterminate"
+ (change)="changeValue(flag)"
+ [name]="flag.code"
+ [id]="flag.code">
+ <label class="custom-control-label"
+ [for]="flag.code"
+ ng-class="['tc_' + key]">
+ <strong>{{ flag.name }}</strong>
+ <span class="badge badge-hdd ml-2"
+ [ngbTooltip]="clusterWideTooltip"
+ *ngIf="flag.clusterWide"
+ i18n>Cluster-wide</span>
+ <br>
+ <span class="form-text text-muted">{{ flag.description }}</span>
+ </label>
+ <hr class="m-1"
+ *ngIf="!last">
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button type="button"
+ class="btn btn-light"
+ (click)="resetSelection()"
+ i18n>Restore previous selection</button>
+ <cd-form-button-panel (submitActionEvent)="submitAction()"
+ [form]="osdFlagsForm"
+ [showSubmit]="permissions.osd.update"
+ [submitText]="actionLabels.UPDATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.scss
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
new file mode 100644
index 000000000..93c9e9adc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.spec.ts
@@ -0,0 +1,353 @@
+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';
+import { of as observableOf } from 'rxjs';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { Flag } from '~/app/shared/models/flag';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdFlagsIndivModalComponent } from './osd-flags-indiv-modal.component';
+
+describe('OsdFlagsIndivModalComponent', () => {
+ let component: OsdFlagsIndivModalComponent;
+ let fixture: ComponentFixture<OsdFlagsIndivModalComponent>;
+ let httpTesting: HttpTestingController;
+ let osdService: OsdService;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbTooltipModule,
+ RouterTestingModule
+ ],
+ declarations: [OsdFlagsIndivModalComponent],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ httpTesting = TestBed.inject(HttpTestingController);
+ fixture = TestBed.createComponent(OsdFlagsIndivModalComponent);
+ component = fixture.componentInstance;
+ osdService = TestBed.inject(OsdService);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('getActivatedIndivFlags', () => {
+ function checkFlagsCount(
+ counts: { [key: string]: number },
+ expected: { [key: string]: number }
+ ) {
+ Object.entries(expected).forEach(([expectedKey, expectedValue]) => {
+ expect(counts[expectedKey]).toBe(expectedValue);
+ });
+ }
+
+ it('should count correctly if no flag has been set', () => {
+ component.selected = generateSelected();
+ const countedFlags = component.getActivatedIndivFlags();
+ checkFlagsCount(countedFlags, { noup: 0, nodown: 0, noin: 0, noout: 0 });
+ });
+
+ it('should count correctly if some of the flags have been set', () => {
+ component.selected = generateSelected([['noin'], ['noin', 'noout'], ['nodown']]);
+ const countedFlags = component.getActivatedIndivFlags();
+ checkFlagsCount(countedFlags, { noup: 0, nodown: 1, noin: 2, noout: 1 });
+ });
+ });
+
+ describe('changeValue', () => {
+ it('should change value correctly and set indeterminate to false', () => {
+ const testFlag = component.flags[0];
+ const value = testFlag.value;
+ component.changeValue(testFlag);
+ expect(testFlag.value).toBe(!value);
+ expect(testFlag.indeterminate).toBeFalsy();
+ });
+ });
+
+ describe('resetSelection', () => {
+ it('should set a new flags object by deep cloning the initial selection', () => {
+ component.resetSelection();
+ expect(component.flags === component.initialSelection).toBeFalsy();
+ });
+ });
+
+ describe('OSD single-select', () => {
+ beforeEach(() => {
+ component.selected = [{ osd: 0 }];
+ });
+
+ describe('ngOnInit', () => {
+ it('should clone flags as initial selection', () => {
+ expect(component.flags === component.initialSelection).toBeFalsy();
+ });
+
+ it('should initialize form correctly if no individual and global flags are set', () => {
+ component.selected[0]['state'] = ['exists', 'up'];
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf([]));
+ fixture.detectChanges();
+ checkFlags(component.flags);
+ });
+
+ it('should initialize form correctly if individual but no global flags are set', () => {
+ component.selected[0]['state'] = ['exists', 'noout', 'up'];
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf([]));
+ fixture.detectChanges();
+ const expected = {
+ noout: { value: true, clusterWide: false, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+
+ it('should initialize form correctly if multiple individual but no global flags are set', () => {
+ component.selected[0]['state'] = ['exists', 'noin', 'noout', 'up'];
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf([]));
+ fixture.detectChanges();
+ const expected = {
+ noout: { value: true, clusterWide: false, indeterminate: false },
+ noin: { value: true, clusterWide: false, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+
+ it('should initialize form correctly if no individual but global flags are set', () => {
+ component.selected[0]['state'] = ['exists', 'up'];
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf(['noout']));
+ fixture.detectChanges();
+ const expected = {
+ noout: { value: false, clusterWide: true, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+ });
+
+ describe('submitAction', () => {
+ let notificationType: NotificationType;
+ let notificationService: NotificationService;
+ let bsModalRef: NgbActiveModal;
+ let flags: object;
+
+ beforeEach(() => {
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.callFake((type) => {
+ notificationType = type;
+ });
+ bsModalRef = TestBed.inject(NgbActiveModal);
+ spyOn(bsModalRef, 'close').and.callThrough();
+ flags = {
+ nodown: false,
+ noin: false,
+ noout: false,
+ noup: false
+ };
+ });
+
+ it('should submit an activated flag', () => {
+ const code = component.flags[0].code;
+ component.flags[0].value = true;
+ component.submitAction();
+ flags[code] = true;
+
+ const req = httpTesting.expectOne('api/osd/flags/individual');
+ req.flush({ flags, ids: [0] });
+ expect(req.request.body).toEqual({ flags, ids: [0] });
+ expect(notificationType).toBe(NotificationType.success);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('should submit multiple flags', () => {
+ const codes = [component.flags[0].code, component.flags[1].code];
+ component.flags[0].value = true;
+ component.flags[1].value = true;
+ component.submitAction();
+ flags[codes[0]] = true;
+ flags[codes[1]] = true;
+
+ const req = httpTesting.expectOne('api/osd/flags/individual');
+ req.flush({ flags, ids: [0] });
+ expect(req.request.body).toEqual({ flags, ids: [0] });
+ expect(notificationType).toBe(NotificationType.success);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('should hide modal if request fails', () => {
+ component.flags = [];
+ component.submitAction();
+ const req = httpTesting.expectOne('api/osd/flags/individual');
+ req.flush([], { status: 500, statusText: 'failure' });
+ expect(notificationService.show).toHaveBeenCalledTimes(0);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('OSD multi-select', () => {
+ describe('ngOnInit', () => {
+ it('should initialize form correctly if same individual and no global flags are set', () => {
+ component.selected = generateSelected([['noin'], ['noin'], ['noin']]);
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf([]));
+ fixture.detectChanges();
+ const expected = {
+ noin: { value: true, clusterWide: false, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+
+ it('should initialize form correctly if different individual and no global flags are set', () => {
+ component.selected = generateSelected([['noin'], ['noout'], ['noin']]);
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf([]));
+ fixture.detectChanges();
+ const expected = {
+ noin: { value: false, clusterWide: false, indeterminate: true },
+ noout: { value: false, clusterWide: false, indeterminate: true }
+ };
+ checkFlags(component.flags, expected);
+ });
+
+ it('should initialize form correctly if different and same individual and no global flags are set', () => {
+ component.selected = generateSelected([
+ ['noin', 'nodown'],
+ ['noout', 'nodown'],
+ ['noin', 'nodown']
+ ]);
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf([]));
+ fixture.detectChanges();
+ const expected = {
+ noin: { value: false, clusterWide: false, indeterminate: true },
+ noout: { value: false, clusterWide: false, indeterminate: true },
+ nodown: { value: true, clusterWide: false, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+
+ it('should initialize form correctly if a flag is set for all OSDs individually and globally', () => {
+ component.selected = generateSelected([
+ ['noin', 'nodown'],
+ ['noout', 'nodown'],
+ ['noin', 'nodown']
+ ]);
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf(['noout']));
+ fixture.detectChanges();
+ const expected = {
+ noin: { value: false, clusterWide: false, indeterminate: true },
+ noout: { value: false, clusterWide: true, indeterminate: true },
+ nodown: { value: true, clusterWide: false, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+
+ it('should initialize form correctly if different individual and global flags are set', () => {
+ component.selected = generateSelected([
+ ['noin', 'nodown', 'noout'],
+ ['noout', 'nodown'],
+ ['noin', 'nodown', 'noout']
+ ]);
+ spyOn(osdService, 'getFlags').and.callFake(() => observableOf(['noout']));
+ fixture.detectChanges();
+ const expected = {
+ noin: { value: false, clusterWide: false, indeterminate: true },
+ noout: { value: true, clusterWide: true, indeterminate: false },
+ nodown: { value: true, clusterWide: false, indeterminate: false }
+ };
+ checkFlags(component.flags, expected);
+ });
+ });
+
+ describe('submitAction', () => {
+ let notificationType: NotificationType;
+ let notificationService: NotificationService;
+ let bsModalRef: NgbActiveModal;
+ let flags: object;
+
+ beforeEach(() => {
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.callFake((type) => {
+ notificationType = type;
+ });
+ bsModalRef = TestBed.inject(NgbActiveModal);
+ spyOn(bsModalRef, 'close').and.callThrough();
+ flags = {
+ nodown: false,
+ noin: false,
+ noout: false,
+ noup: false
+ };
+ });
+
+ it('should submit an activated flag for multiple OSDs', () => {
+ component.selected = generateSelected();
+ const code = component.flags[0].code;
+ const submittedIds = [0, 1, 2];
+ component.flags[0].value = true;
+ component.submitAction();
+ flags[code] = true;
+
+ const req = httpTesting.expectOne('api/osd/flags/individual');
+ req.flush({ flags, ids: submittedIds });
+ expect(req.request.body).toEqual({ flags, ids: submittedIds });
+ expect(notificationType).toBe(NotificationType.success);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('should submit multiple flags for multiple OSDs', () => {
+ component.selected = generateSelected();
+ const codes = [component.flags[0].code, component.flags[1].code];
+ const submittedIds = [0, 1, 2];
+ component.flags[0].value = true;
+ component.flags[1].value = true;
+ component.submitAction();
+ flags[codes[0]] = true;
+ flags[codes[1]] = true;
+
+ const req = httpTesting.expectOne('api/osd/flags/individual');
+ req.flush({ flags, ids: submittedIds });
+ expect(req.request.body).toEqual({ flags, ids: submittedIds });
+ expect(notificationType).toBe(NotificationType.success);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ function checkFlags(flags: Flag[], expected: object = {}) {
+ flags.forEach((flag) => {
+ let value = false;
+ let clusterWide = false;
+ let indeterminate = false;
+ if (Object.keys(expected).includes(flag.code)) {
+ value = expected[flag.code]['value'];
+ clusterWide = expected[flag.code]['clusterWide'];
+ indeterminate = expected[flag.code]['indeterminate'];
+ }
+ expect(flag.value).toBe(value);
+ expect(flag.clusterWide).toBe(clusterWide);
+ expect(flag.indeterminate).toBe(indeterminate);
+ });
+ }
+
+ function generateSelected(flags: string[][] = []) {
+ const defaultFlags = ['exists', 'up'];
+ const osds = [];
+ const count = flags.length || 3;
+ for (let i = 0; i < count; i++) {
+ const osd = {
+ osd: i,
+ state: defaultFlags.concat(flags[i]) || defaultFlags
+ };
+ osds.push(osd);
+ }
+ return osds;
+ }
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts
new file mode 100644
index 000000000..e9e0b876f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-indiv-modal/osd-flags-indiv-modal.component.ts
@@ -0,0 +1,134 @@
+import { Component, OnInit } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { Flag } from '~/app/shared/models/flag';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-osd-flags-indiv-modal',
+ templateUrl: './osd-flags-indiv-modal.component.html',
+ styleUrls: ['./osd-flags-indiv-modal.component.scss']
+})
+export class OsdFlagsIndivModalComponent implements OnInit {
+ permissions: Permissions;
+ selected: object[];
+ initialSelection: Flag[] = [];
+ osdFlagsForm = new FormGroup({});
+ flags: Flag[] = [
+ {
+ code: 'noup',
+ name: $localize`No Up`,
+ description: $localize`OSDs are not allowed to start`,
+ value: false,
+ clusterWide: false,
+ indeterminate: false
+ },
+ {
+ code: 'nodown',
+ name: $localize`No Down`,
+ description: $localize`OSD failure reports are being ignored, such that the monitors will not mark OSDs down`,
+ value: false,
+ clusterWide: false,
+ indeterminate: false
+ },
+ {
+ code: 'noin',
+ name: $localize`No In`,
+ description: $localize`OSDs that were previously marked out will not be marked back in when they start`,
+ value: false,
+ clusterWide: false,
+ indeterminate: false
+ },
+ {
+ code: 'noout',
+ name: $localize`No Out`,
+ description: $localize`OSDs will not automatically be marked out after the configured interval`,
+ value: false,
+ clusterWide: false,
+ indeterminate: false
+ }
+ ];
+ clusterWideTooltip: string = $localize`The flag has been enabled for the entire cluster.`;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private osdService: OsdService,
+ private notificationService: NotificationService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ }
+
+ ngOnInit() {
+ const osdCount = this.selected.length;
+ this.osdService.getFlags().subscribe((clusterWideFlags: string[]) => {
+ const activatedIndivFlags = this.getActivatedIndivFlags();
+ this.flags.forEach((flag) => {
+ const flagCount = activatedIndivFlags[flag.code];
+ if (clusterWideFlags.includes(flag.code)) {
+ flag.clusterWide = true;
+ }
+
+ if (flagCount === osdCount) {
+ flag.value = true;
+ } else if (flagCount > 0) {
+ flag.indeterminate = true;
+ }
+ });
+ this.initialSelection = _.cloneDeep(this.flags);
+ });
+ }
+
+ getActivatedIndivFlags(): { [flag: string]: number } {
+ const flagsCount = {};
+ this.flags.forEach((flag) => {
+ flagsCount[flag.code] = 0;
+ });
+
+ [].concat(...this.selected.map((osd) => osd['state'])).map((activatedFlag) => {
+ if (Object.keys(flagsCount).includes(activatedFlag)) {
+ flagsCount[activatedFlag] = flagsCount[activatedFlag] + 1;
+ }
+ });
+ return flagsCount;
+ }
+
+ changeValue(flag: Flag) {
+ flag.value = !flag.value;
+ flag.indeterminate = false;
+ }
+
+ resetSelection() {
+ this.flags = _.cloneDeep(this.initialSelection);
+ }
+
+ submitAction() {
+ const activeFlags = {};
+ this.flags.forEach((flag) => {
+ if (flag.indeterminate) {
+ activeFlags[flag.code] = null;
+ } else {
+ activeFlags[flag.code] = flag.value;
+ }
+ });
+ const selectedIds = this.selected.map((selection) => selection['osd']);
+ this.osdService.updateIndividualFlags(activeFlags, selectedIds).subscribe(
+ () => {
+ this.notificationService.show(NotificationType.success, $localize`Updated OSD Flags`);
+ this.activeModal.close();
+ },
+ () => {
+ this.activeModal.close();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html
new file mode 100644
index 000000000..2ae6460fb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html
@@ -0,0 +1,41 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>Cluster-wide OSD Flags</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="osdFlagsForm"
+ #formDir="ngForm"
+ [formGroup]="osdFlagsForm"
+ novalidate
+ cdFormScope="osd">
+ <div class="modal-body osd-modal">
+ <div class="custom-control custom-checkbox"
+ *ngFor="let flag of flags; let last = last">
+ <input class="custom-control-input"
+ type="checkbox"
+ [checked]="flag.value"
+ (change)="flag.value = !flag.value"
+ [name]="flag.code"
+ [id]="flag.code"
+ [disabled]="flag.disabled">
+ <label class="custom-control-label"
+ [for]="flag.code"
+ ng-class="['tc_' + key]">
+ <strong>{{ flag.name }}</strong>
+ <br>
+ <span class="form-text text-muted">{{ flag.description }}</span>
+ </label>
+ <hr class="m-1"
+ *ngIf="!last">
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submitAction()"
+ [form]="osdFlagsForm"
+ [showSubmit]="permissions.osd.update"
+ [submitText]="actionLabels.UPDATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.spec.ts
new file mode 100644
index 000000000..b6bea06f9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.spec.ts
@@ -0,0 +1,99 @@
+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 } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdFlagsModalComponent } from './osd-flags-modal.component';
+
+function getFlagsArray(component: OsdFlagsModalComponent) {
+ const allFlags = _.cloneDeep(component.allFlags);
+ allFlags['purged_snapdirs'].value = true;
+ allFlags['pause'].value = true;
+ return _.toArray(allFlags);
+}
+
+describe('OsdFlagsModalComponent', () => {
+ let component: OsdFlagsModalComponent;
+ let fixture: ComponentFixture<OsdFlagsModalComponent>;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [
+ ReactiveFormsModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [OsdFlagsModalComponent],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ httpTesting = TestBed.inject(HttpTestingController);
+ fixture = TestBed.createComponent(OsdFlagsModalComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should finish running ngOnInit', () => {
+ fixture.detectChanges();
+
+ const flags = getFlagsArray(component);
+
+ const req = httpTesting.expectOne('api/osd/flags');
+ req.flush(['purged_snapdirs', 'pause', 'foo']);
+
+ expect(component.flags).toEqual(flags);
+ expect(component.unknownFlags).toEqual(['foo']);
+ });
+
+ describe('test submitAction', function () {
+ let notificationType: NotificationType;
+ let notificationService: NotificationService;
+ let bsModalRef: NgbActiveModal;
+
+ beforeEach(() => {
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.callFake((type) => {
+ notificationType = type;
+ });
+
+ bsModalRef = TestBed.inject(NgbActiveModal);
+ spyOn(bsModalRef, 'close').and.callThrough();
+ component.unknownFlags = ['foo'];
+ });
+
+ it('should run submitAction', () => {
+ component.flags = getFlagsArray(component);
+ component.submitAction();
+ const req = httpTesting.expectOne('api/osd/flags');
+ req.flush(['purged_snapdirs', 'pause', 'foo']);
+ expect(req.request.body).toEqual({ flags: ['pause', 'purged_snapdirs', 'foo'] });
+
+ expect(notificationType).toBe(NotificationType.success);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('should hide modal if request fails', () => {
+ component.flags = [];
+ component.submitAction();
+ const req = httpTesting.expectOne('api/osd/flags');
+ req.flush([], { status: 500, statusText: 'failure' });
+
+ expect(notificationService.show).toHaveBeenCalledTimes(0);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.ts
new file mode 100644
index 000000000..640719382
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.ts
@@ -0,0 +1,156 @@
+import { Component, OnInit } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-osd-flags-modal',
+ templateUrl: './osd-flags-modal.component.html',
+ styleUrls: ['./osd-flags-modal.component.scss']
+})
+export class OsdFlagsModalComponent implements OnInit {
+ permissions: Permissions;
+
+ osdFlagsForm = new FormGroup({});
+
+ allFlags = {
+ noin: {
+ code: 'noin',
+ name: $localize`No In`,
+ value: false,
+ description: $localize`OSDs that were previously marked out will not be marked back in when they start`
+ },
+ noout: {
+ code: 'noout',
+ name: $localize`No Out`,
+ value: false,
+ description: $localize`OSDs will not automatically be marked out after the configured interval`
+ },
+ noup: {
+ code: 'noup',
+ name: $localize`No Up`,
+ value: false,
+ description: $localize`OSDs are not allowed to start`
+ },
+ nodown: {
+ code: 'nodown',
+ name: $localize`No Down`,
+ value: false,
+ description: $localize`OSD failure reports are being ignored, such that the monitors will not mark OSDs down`
+ },
+ pause: {
+ code: 'pause',
+ name: $localize`Pause`,
+ value: false,
+ description: $localize`Pauses reads and writes`
+ },
+ noscrub: {
+ code: 'noscrub',
+ name: $localize`No Scrub`,
+ value: false,
+ description: $localize`Scrubbing is disabled`
+ },
+ 'nodeep-scrub': {
+ code: 'nodeep-scrub',
+ name: $localize`No Deep Scrub`,
+ value: false,
+ description: $localize`Deep Scrubbing is disabled`
+ },
+ nobackfill: {
+ code: 'nobackfill',
+ name: $localize`No Backfill`,
+ value: false,
+ description: $localize`Backfilling of PGs is suspended`
+ },
+ norebalance: {
+ code: 'norebalance',
+ name: $localize`No Rebalance`,
+ value: false,
+ description: $localize`OSD will choose not to backfill unless PG is also degraded`
+ },
+ norecover: {
+ code: 'norecover',
+ name: $localize`No Recover`,
+ value: false,
+ description: $localize`Recovery of PGs is suspended`
+ },
+ sortbitwise: {
+ code: 'sortbitwise',
+ name: $localize`Bitwise Sort`,
+ value: false,
+ description: $localize`Use bitwise sort`,
+ disabled: true
+ },
+ purged_snapdirs: {
+ code: 'purged_snapdirs',
+ name: $localize`Purged Snapdirs`,
+ value: false,
+ description: $localize`OSDs have converted snapsets`,
+ disabled: true
+ },
+ recovery_deletes: {
+ code: 'recovery_deletes',
+ name: $localize`Recovery Deletes`,
+ value: false,
+ description: $localize`Deletes performed during recovery instead of peering`,
+ disabled: true
+ },
+ pglog_hardlimit: {
+ code: 'pglog_hardlimit',
+ name: $localize`PG Log Hard Limit`,
+ value: false,
+ description: $localize`Puts a hard limit on pg log length`,
+ disabled: true
+ }
+ };
+ flags: any[];
+ unknownFlags: string[] = [];
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private osdService: OsdService,
+ private notificationService: NotificationService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ }
+
+ ngOnInit() {
+ this.osdService.getFlags().subscribe((res: string[]) => {
+ res.forEach((value) => {
+ if (this.allFlags[value]) {
+ this.allFlags[value].value = true;
+ } else {
+ this.unknownFlags.push(value);
+ }
+ });
+ this.flags = _.toArray(this.allFlags);
+ });
+ }
+
+ submitAction() {
+ const newFlags = this.flags
+ .filter((flag) => flag.value)
+ .map((flag) => flag.code)
+ .concat(this.unknownFlags);
+
+ this.osdService.updateFlags(newFlags).subscribe(
+ () => {
+ this.notificationService.show(NotificationType.success, $localize`Updated OSD Flags`);
+ this.activeModal.close();
+ },
+ () => {
+ this.activeModal.close();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts
new file mode 100644
index 000000000..841e947b8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts
@@ -0,0 +1,97 @@
+import _ from 'lodash';
+
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+export class DriveGroup {
+ spec: Object;
+
+ // Map from filter column prop to device selection attribute name
+ private deviceSelectionAttrs: {
+ [key: string]: {
+ name: string;
+ formatter?: Function;
+ };
+ };
+
+ private formatterService: FormatterService;
+
+ constructor() {
+ this.reset();
+ this.formatterService = new FormatterService();
+ this.deviceSelectionAttrs = {
+ 'sys_api.vendor': {
+ name: 'vendor'
+ },
+ 'sys_api.model': {
+ name: 'model'
+ },
+ device_id: {
+ name: 'device_id'
+ },
+ human_readable_type: {
+ name: 'rotational',
+ formatter: (value: string) => {
+ return value.toLowerCase() === 'hdd';
+ }
+ },
+ 'sys_api.size': {
+ name: 'size',
+ formatter: (value: string) => {
+ return this.formatterService
+ .format_number(value, 1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB'])
+ .replace(' ', '');
+ }
+ }
+ };
+ }
+
+ reset() {
+ this.spec = {
+ service_type: 'osd',
+ service_id: `dashboard-${_.now()}`
+ };
+ }
+
+ setName(name: string) {
+ this.spec['service_id'] = name;
+ }
+
+ setHostPattern(pattern: string) {
+ this.spec['host_pattern'] = pattern;
+ }
+
+ setDeviceSelection(type: string, appliedFilters: CdTableColumnFiltersChange['filters']) {
+ const key = `${type}_devices`;
+ this.spec[key] = {};
+ appliedFilters.forEach((filter) => {
+ const attr = this.deviceSelectionAttrs[filter.prop];
+ if (attr) {
+ const name = attr.name;
+ this.spec[key][name] = attr.formatter ? attr.formatter(filter.value.raw) : filter.value.raw;
+ }
+ });
+ }
+
+ clearDeviceSelection(type: string) {
+ const key = `${type}_devices`;
+ delete this.spec[key];
+ }
+
+ setSlots(type: string, slots: number) {
+ const key = `${type}_slots`;
+ if (slots === 0) {
+ delete this.spec[key];
+ } else {
+ this.spec[key] = slots;
+ }
+ }
+
+ setFeature(feature: string, enabled: boolean) {
+ if (enabled) {
+ this.spec[feature] = true;
+ } else {
+ delete this.spec[feature];
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts
new file mode 100644
index 000000000..8c9dc452e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts
@@ -0,0 +1,4 @@
+export interface OsdFeature {
+ desc: string;
+ key?: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html
new file mode 100644
index 000000000..d4b6d9fae
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html
@@ -0,0 +1,213 @@
+<cd-orchestrator-doc-panel *ngIf="!hasOrchestrator"></cd-orchestrator-doc-panel>
+
+<div class="card"
+ *cdFormLoading="loading">
+ <div i18n="form title|Example: Create Pool@@formTitle"
+ class="card-header"
+ *ngIf="!hideTitle">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+ <div class="card-body ml-2">
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="accordion">
+ <div class="card">
+ <div class="card-header">
+ <h2 class="mb-0">
+ <button class="btn btn-link btn-block text-left dropdown-toggle"
+ data-toggle="collapse"
+ aria-label="toggle deployment options"
+ [attr.aria-expanded]="simpleDeployment"
+ (click)="emitDeploymentMode()"
+ i18n>Deployment Options</button>
+ </h2>
+ </div>
+ </div>
+ <div class="collapse"
+ [ngClass]="{show: simpleDeployment}">
+ <div class="card-body d-flex flex-column">
+ <div class="pt-3 pb-3"
+ *ngFor="let optionName of optionNames">
+ <div class="custom-control custom-radio custom-control-inline">
+ <input class="custom-control-input"
+ type="radio"
+ name="deploymentOption"
+ [id]="optionName"
+ [value]="optionName"
+ formControlName="deploymentOption"
+ (change)="emitDeploymentSelection()"
+ [attr.disabled]="!deploymentOptions?.options[optionName].available ? true : null">
+ <label class="custom-control-label"
+ [id]="'label_' + optionName"
+ [for]="optionName"
+ i18n>{{ deploymentOptions?.options[optionName].title }}
+ {{ deploymentOptions.recommended_option === optionName ? "(Recommended)" : "" }}
+ <cd-helper>
+ <span>{{ deploymentOptions?.options[optionName].desc }}</span>
+ </cd-helper>
+ </label>
+ </div>
+ </div>
+ <!-- @TODO: Visualize the storage used on a chart -->
+ <!-- <div class="pie-chart">
+ <h4 class="text-center">Selected Capacity</h4>
+ <h5 class="margin text-center">10 Hosts | 30 NVMes </h5>
+ <div class="char-i-contain">
+ <cd-health-pie [data]="data"
+ [config]="rawCapacityChartConfig"
+ [isBytesData]="true"
+ (prepareFn)="prepareRawUsage($event[0], $event[1])">
+ </cd-health-pie>
+ </div>
+ </div> -->
+ </div>
+ </div>
+ <div class="card">
+ <div class="card-header">
+ <h2 class="mb-0">
+ <button class="btn btn-link btn-block text-left dropdown-toggle"
+ data-toggle="collapse"
+ aria-label="toggle advanced mode"
+ [attr.aria-expanded]="!simpleDeployment"
+ (click)="emitDeploymentMode()"
+ i18n>Advanced Mode</button>
+ </h2>
+ </div>
+ </div>
+ <div class="collapse"
+ [ngClass]="{show: !simpleDeployment}">
+ <div class="card-body">
+ <div class="card-body">
+ <fieldset>
+ <cd-osd-devices-selection-groups #dataDeviceSelectionGroups
+ name="Primary"
+ type="data"
+ [availDevices]="availDevices"
+ [canSelect]="availDevices.length !== 0"
+ (selected)="onDevicesSelected($event)"
+ (cleared)="onDevicesCleared($event)">
+ </cd-osd-devices-selection-groups>
+ </fieldset>
+
+ <!-- Shared devices -->
+ <fieldset>
+ <legend i18n>Shared devices</legend>
+
+ <!-- WAL devices button and table -->
+ <cd-osd-devices-selection-groups #walDeviceSelectionGroups
+ name="WAL"
+ type="wal"
+ [availDevices]="availDevices"
+ [canSelect]="dataDeviceSelectionGroups.devices.length !== 0"
+ (selected)="onDevicesSelected($event)"
+ (cleared)="onDevicesCleared($event)">
+ </cd-osd-devices-selection-groups>
+
+ <!-- WAL slots -->
+ <div class="form-group row"
+ *ngIf="walDeviceSelectionGroups.devices.length !== 0">
+ <label class="cd-col-form-label"
+ for="walSlots">
+ <ng-container i18n>WAL slots</ng-container>
+ <cd-helper>
+ <span i18n>How many OSDs per WAL device.</span>
+ <br>
+ <span i18n>Specify 0 to let Orchestrator backend decide it.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="walSlots"
+ name="walSlots"
+ type="number"
+ min="0"
+ formControlName="walSlots">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('walSlots', formDir, 'min')"
+ i18n>Value should be greater than or equal to 0</span>
+ </div>
+ </div>
+
+ <!-- DB devices button and table -->
+ <cd-osd-devices-selection-groups #dbDeviceSelectionGroups
+ name="DB"
+ type="db"
+ [availDevices]="availDevices"
+ [canSelect]="dataDeviceSelectionGroups.devices.length !== 0"
+ (selected)="onDevicesSelected($event)"
+ (cleared)="onDevicesCleared($event)">
+ </cd-osd-devices-selection-groups>
+
+ <!-- DB slots -->
+ <div class="form-group row"
+ *ngIf="dbDeviceSelectionGroups.devices.length !== 0">
+ <label class="cd-col-form-label"
+ for="dbSlots">
+ <ng-container i18n>DB slots</ng-container>
+ <cd-helper>
+ <span i18n>How many OSDs per DB device.</span>
+ <br>
+ <span i18n>Specify 0 to let Orchestrator backend decide it.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="dbSlots"
+ name="dbSlots"
+ type="number"
+ min="0"
+ formControlName="dbSlots">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('dbSlots', formDir, 'min')"
+ i18n>Value should be greater than or equal to 0</span>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ </div>
+ </div>
+
+ <!-- Features -->
+ <div class="card">
+ <div class="card-header">
+ <h2 class="mb-0">
+ <button class="btn btn-link btn-block text-left dropdown-toggle"
+ data-toggle="collapse"
+ aria-label="features"
+ aria-expanded="true"
+ i18n>Features</button>
+ </h2>
+ </div>
+ </div>
+ <div class="collapse show">
+ <div class="card-body d-flex flex-column">
+ <div class="pt-3 pb-3"
+ formGroupName="features">
+ <div class="custom-control custom-checkbox"
+ *ngFor="let feature of featureList">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="{{ feature.key }}"
+ name="{{ feature.key }}"
+ formControlName="{{ feature.key }}"
+ (change)="emitDeploymentSelection()">
+ <label class="custom-control-label"
+ for="{{ feature.key }}">{{ feature.desc }}</label>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <div class="card-footer"
+ *ngIf="!hideSubmitBtn">
+ <cd-form-button-panel #previewButtonPanel
+ (submitActionEvent)="submit()"
+ [form]="form"
+ [disabled]="dataDeviceSelectionGroups.devices.length === 0 && !simpleDeployment"
+ [submitText]="simpleDeployment ? 'Create OSDs' : actionLabels.PREVIEW"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts
new file mode 100644
index 000000000..725fc953f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts
@@ -0,0 +1,309 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { InventoryDevicesComponent } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component';
+import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import {
+ DeploymentOptions,
+ OsdDeploymentOptions
+} from '~/app/shared/models/osd-deployment-options';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FixtureHelper, FormHelper } from '~/testing/unit-test-helper';
+import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface';
+import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface';
+import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component';
+import { OsdFormComponent } from './osd-form.component';
+
+describe('OsdFormComponent', () => {
+ let form: CdFormGroup;
+ let component: OsdFormComponent;
+ let formHelper: FormHelper;
+ let fixture: ComponentFixture<OsdFormComponent>;
+ let fixtureHelper: FixtureHelper;
+ let orchService: OrchestratorService;
+ let hostService: HostService;
+ let summaryService: SummaryService;
+ const devices: InventoryDevice[] = [
+ {
+ hostname: 'node0',
+ uid: '1',
+
+ path: '/dev/sda',
+ sys_api: {
+ vendor: 'VENDOR',
+ model: 'MODEL',
+ size: 1024,
+ rotational: 'false',
+ human_readable_size: '1 KB'
+ },
+ available: true,
+ rejected_reasons: [''],
+ device_id: 'VENDOR-MODEL-ID',
+ human_readable_type: 'nvme/ssd',
+ osd_ids: []
+ }
+ ];
+
+ const deploymentOptions: DeploymentOptions = {
+ options: {
+ cost_capacity: {
+ name: OsdDeploymentOptions.COST_CAPACITY,
+ available: true,
+ capacity: 0,
+ used: 0,
+ hdd_used: 0,
+ ssd_used: 0,
+ nvme_used: 0,
+ title: 'Cost/Capacity-optimized',
+ desc: 'All the available HDDs are selected'
+ },
+ throughput_optimized: {
+ name: OsdDeploymentOptions.THROUGHPUT,
+ available: false,
+ capacity: 0,
+ used: 0,
+ hdd_used: 0,
+ ssd_used: 0,
+ nvme_used: 0,
+ title: 'Throughput-optimized',
+ desc: 'HDDs/SSDs are selected for data devices and SSDs/NVMes for DB/WAL devices'
+ },
+ iops_optimized: {
+ name: OsdDeploymentOptions.IOPS,
+ available: false,
+ capacity: 0,
+ used: 0,
+ hdd_used: 0,
+ ssd_used: 0,
+ nvme_used: 0,
+ title: 'IOPS-optimized',
+ desc: 'All the available NVMes are selected'
+ }
+ },
+ recommended_option: OsdDeploymentOptions.COST_CAPACITY
+ };
+
+ const expectPreviewButton = (enabled: boolean) => {
+ const debugElement = fixtureHelper.getElementByCss('.tc_submitButton');
+ expect(debugElement.nativeElement.disabled).toBe(!enabled);
+ };
+
+ const selectDevices = (type: string) => {
+ const event: DevicesSelectionChangeEvent = {
+ type: type,
+ filters: [],
+ data: devices,
+ dataOut: []
+ };
+ component.onDevicesSelected(event);
+ if (type === 'data') {
+ component.dataDeviceSelectionGroups.devices = devices;
+ } else if (type === 'wal') {
+ component.walDeviceSelectionGroups.devices = devices;
+ } else if (type === 'db') {
+ component.dbDeviceSelectionGroups.devices = devices;
+ }
+ fixture.detectChanges();
+ };
+
+ const clearDevices = (type: string) => {
+ const event: DevicesSelectionClearEvent = {
+ type: type,
+ clearedDevices: []
+ };
+ component.onDevicesCleared(event);
+ fixture.detectChanges();
+ };
+
+ const features = ['encrypted'];
+ const checkFeatures = (enabled: boolean) => {
+ for (const feature of features) {
+ const element = fixtureHelper.getElementByCss(`#${feature}`).nativeElement;
+ expect(element.disabled).toBe(!enabled);
+ expect(element.checked).toBe(false);
+ }
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ FormsModule,
+ SharedModule,
+ RouterTestingModule,
+ ReactiveFormsModule,
+ ToastrModule.forRoot(),
+ DashboardModule
+ ],
+ declarations: [OsdFormComponent, OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdFormComponent);
+ fixtureHelper = new FixtureHelper(fixture);
+ component = fixture.componentInstance;
+ form = component.form;
+ formHelper = new FormHelper(form);
+ orchService = TestBed.inject(OrchestratorService);
+ hostService = TestBed.inject(HostService);
+ summaryService = TestBed.inject(SummaryService);
+ summaryService['summaryDataSource'] = new BehaviorSubject(null);
+ summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable();
+ summaryService['summaryDataSource'].next({ version: 'master' });
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('without orchestrator', () => {
+ beforeEach(() => {
+ spyOn(orchService, 'status').and.returnValue(of({ available: false }));
+ spyOn(hostService, 'inventoryDeviceList').and.callThrough();
+ fixture.detectChanges();
+ });
+
+ it('should display info panel to document', () => {
+ fixtureHelper.expectElementVisible('cd-alert-panel', true);
+ fixtureHelper.expectElementVisible('.col-sm-10 form', false);
+ });
+
+ it('should not call inventoryDeviceList', () => {
+ expect(hostService.inventoryDeviceList).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with orchestrator', () => {
+ beforeEach(() => {
+ component.simpleDeployment = false;
+ spyOn(orchService, 'status').and.returnValue(of({ available: true }));
+ spyOn(hostService, 'inventoryDeviceList').and.returnValue(of([]));
+ component.deploymentOptions = deploymentOptions;
+ fixture.detectChanges();
+ });
+
+ it('should display the accordion', () => {
+ fixtureHelper.expectElementVisible('.card-body .accordion', true);
+ });
+
+ it('should display the three deployment scenarios', () => {
+ fixtureHelper.expectElementVisible('#cost_capacity', true);
+ fixtureHelper.expectElementVisible('#throughput_optimized', true);
+ fixtureHelper.expectElementVisible('#iops_optimized', true);
+ });
+
+ it('should only disable the options that are not available', () => {
+ let radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement;
+ expect(radioBtn.disabled).toBeTruthy();
+ radioBtn = fixtureHelper.getElementByCss('#iops_optimized').nativeElement;
+ expect(radioBtn.disabled).toBeTruthy();
+
+ // Make the throughput_optimized option available and verify the option is not disabled
+ deploymentOptions.options['throughput_optimized'].available = true;
+ fixture.detectChanges();
+ radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement;
+ expect(radioBtn.disabled).toBeFalsy();
+ });
+
+ it('should be a Recommended option only when it is recommended by backend', () => {
+ const label = fixtureHelper.getElementByCss('#label_cost_capacity').nativeElement;
+ const throughputLabel = fixtureHelper.getElementByCss('#label_throughput_optimized')
+ .nativeElement;
+
+ expect(label.innerHTML).toContain('Recommended');
+ expect(throughputLabel.innerHTML).not.toContain('Recommended');
+
+ deploymentOptions.recommended_option = OsdDeploymentOptions.THROUGHPUT;
+ fixture.detectChanges();
+ expect(throughputLabel.innerHTML).toContain('Recommended');
+ expect(label.innerHTML).not.toContain('Recommended');
+ });
+
+ it('should display form', () => {
+ fixtureHelper.expectElementVisible('cd-alert-panel', false);
+ fixtureHelper.expectElementVisible('.card-body form', true);
+ });
+
+ describe('without data devices selected', () => {
+ it('should disable preview button', () => {
+ expectPreviewButton(false);
+ });
+
+ it('should not display shared devices slots', () => {
+ fixtureHelper.expectElementVisible('#walSlots', false);
+ fixtureHelper.expectElementVisible('#dbSlots', false);
+ });
+
+ it('should disable the checkboxes', () => {
+ checkFeatures(false);
+ });
+ });
+
+ describe('with data devices selected', () => {
+ beforeEach(() => {
+ selectDevices('data');
+ });
+
+ it('should enable preview button', () => {
+ expectPreviewButton(true);
+ });
+
+ it('should not display shared devices slots', () => {
+ fixtureHelper.expectElementVisible('#walSlots', false);
+ fixtureHelper.expectElementVisible('#dbSlots', false);
+ });
+
+ it('should enable the checkboxes', () => {
+ checkFeatures(true);
+ });
+
+ it('should disable the checkboxes after clearing data devices', () => {
+ clearDevices('data');
+ checkFeatures(false);
+ });
+
+ describe('with shared devices selected', () => {
+ beforeEach(() => {
+ selectDevices('wal');
+ selectDevices('db');
+ });
+
+ it('should display slots', () => {
+ fixtureHelper.expectElementVisible('#walSlots', true);
+ fixtureHelper.expectElementVisible('#dbSlots', true);
+ });
+
+ it('validate slots', () => {
+ for (const control of ['walSlots', 'dbSlots']) {
+ formHelper.expectValid(control);
+ formHelper.expectValidChange(control, 1);
+ formHelper.expectErrorChange(control, -1, 'min');
+ }
+ });
+
+ describe('test clearing data devices', () => {
+ beforeEach(() => {
+ clearDevices('data');
+ });
+
+ it('should not display shared devices slots and should disable checkboxes', () => {
+ fixtureHelper.expectElementVisible('#walSlots', false);
+ fixtureHelper.expectElementVisible('#dbSlots', false);
+ checkFeatures(false);
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts
new file mode 100644
index 000000000..c2384425e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts
@@ -0,0 +1,285 @@
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import _ from 'lodash';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { FormButtonPanelComponent } from '~/app/shared/components/form-button-panel/form-button-panel.component';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import {
+ DeploymentOptions,
+ OsdDeploymentOptions
+} from '~/app/shared/models/osd-deployment-options';
+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 { OsdCreationPreviewModalComponent } from '../osd-creation-preview-modal/osd-creation-preview-modal.component';
+import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface';
+import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface';
+import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component';
+import { DriveGroup } from './drive-group.model';
+import { OsdFeature } from './osd-feature.interface';
+
+@Component({
+ selector: 'cd-osd-form',
+ templateUrl: './osd-form.component.html',
+ styleUrls: ['./osd-form.component.scss']
+})
+export class OsdFormComponent extends CdForm implements OnInit {
+ @ViewChild('dataDeviceSelectionGroups')
+ dataDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent;
+
+ @ViewChild('walDeviceSelectionGroups')
+ walDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent;
+
+ @ViewChild('dbDeviceSelectionGroups')
+ dbDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent;
+
+ @ViewChild('previewButtonPanel')
+ previewButtonPanel: FormButtonPanelComponent;
+
+ @Input()
+ hideTitle = false;
+
+ @Input()
+ hideSubmitBtn = false;
+
+ @Output() emitDriveGroup: EventEmitter<DriveGroup> = new EventEmitter();
+
+ @Output() emitDeploymentOption: EventEmitter<object> = new EventEmitter();
+
+ @Output() emitMode: EventEmitter<boolean> = new EventEmitter();
+
+ icons = Icons;
+
+ form: CdFormGroup;
+ columns: Array<CdTableColumn> = [];
+
+ allDevices: InventoryDevice[] = [];
+
+ availDevices: InventoryDevice[] = [];
+ dataDeviceFilters: any[] = [];
+ dbDeviceFilters: any[] = [];
+ walDeviceFilters: any[] = [];
+ hostname = '';
+ driveGroup = new DriveGroup();
+
+ action: string;
+ resource: string;
+
+ features: { [key: string]: OsdFeature };
+ featureList: OsdFeature[] = [];
+
+ hasOrchestrator = true;
+
+ simpleDeployment = true;
+
+ deploymentOptions: DeploymentOptions;
+ optionNames = Object.values(OsdDeploymentOptions);
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private orchService: OrchestratorService,
+ private hostService: HostService,
+ private router: Router,
+ private modalService: ModalService,
+ private osdService: OsdService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ super();
+ this.resource = $localize`OSDs`;
+ this.action = this.actionLabels.CREATE;
+ this.features = {
+ encrypted: {
+ key: 'encrypted',
+ desc: $localize`Encryption`
+ }
+ };
+ this.featureList = _.map(this.features, (o, key) => Object.assign(o, { key: key }));
+ this.createForm();
+ }
+
+ ngOnInit() {
+ this.orchService.status().subscribe((status) => {
+ this.hasOrchestrator = status.available;
+ if (status.available) {
+ this.getDataDevices();
+ } else {
+ this.loadingNone();
+ }
+ });
+
+ this.osdService.getDeploymentOptions().subscribe((options) => {
+ this.deploymentOptions = options;
+ this.form.get('deploymentOption').setValue(this.deploymentOptions?.recommended_option);
+
+ if (this.deploymentOptions?.recommended_option) {
+ this.enableFeatures();
+ }
+ });
+ this.form.get('walSlots').valueChanges.subscribe((value) => this.setSlots('wal', value));
+ this.form.get('dbSlots').valueChanges.subscribe((value) => this.setSlots('db', value));
+ _.each(this.features, (feature) => {
+ this.form
+ .get('features')
+ .get(feature.key)
+ .valueChanges.subscribe((value) => this.featureFormUpdate(feature.key, value));
+ });
+ }
+
+ createForm() {
+ this.form = new CdFormGroup({
+ walSlots: new FormControl(0),
+ dbSlots: new FormControl(0),
+ features: new CdFormGroup(
+ this.featureList.reduce((acc: object, e) => {
+ // disable initially because no data devices are selected
+ acc[e.key] = new FormControl({ value: false, disabled: true });
+ return acc;
+ }, {})
+ ),
+ deploymentOption: new FormControl(0)
+ });
+ }
+
+ getDataDevices() {
+ this.hostService.inventoryDeviceList().subscribe(
+ (devices: InventoryDevice[]) => {
+ this.allDevices = _.filter(devices, 'available');
+ this.availDevices = [...this.allDevices];
+ this.loadingReady();
+ },
+ () => {
+ this.allDevices = [];
+ this.availDevices = [];
+ this.loadingError();
+ }
+ );
+ }
+
+ setSlots(type: string, slots: number) {
+ if (typeof slots !== 'number') {
+ return;
+ }
+ if (slots >= 0) {
+ this.driveGroup.setSlots(type, slots);
+ }
+ }
+
+ featureFormUpdate(key: string, checked: boolean) {
+ this.driveGroup.setFeature(key, checked);
+ }
+
+ enableFeatures() {
+ this.featureList.forEach((feature) => {
+ this.form.get(feature.key).enable({ emitEvent: false });
+ });
+ }
+
+ disableFeatures() {
+ this.featureList.forEach((feature) => {
+ const control = this.form.get(feature.key);
+ control.disable({ emitEvent: false });
+ control.setValue(false, { emitEvent: false });
+ });
+ }
+
+ onDevicesSelected(event: DevicesSelectionChangeEvent) {
+ this.availDevices = event.dataOut;
+
+ if (event.type === 'data') {
+ // If user selects data devices for a single host, make only remaining devices on
+ // that host as available.
+ const hostnameFilter = _.find(event.filters, { prop: 'hostname' });
+ if (hostnameFilter) {
+ this.hostname = hostnameFilter.value.raw;
+ this.availDevices = event.dataOut.filter((device: InventoryDevice) => {
+ return device.hostname === this.hostname;
+ });
+ this.driveGroup.setHostPattern(this.hostname);
+ } else {
+ this.driveGroup.setHostPattern('*');
+ }
+ this.enableFeatures();
+ }
+ this.driveGroup.setDeviceSelection(event.type, event.filters);
+
+ this.emitDriveGroup.emit(this.driveGroup);
+ }
+
+ onDevicesCleared(event: DevicesSelectionClearEvent) {
+ if (event.type === 'data') {
+ this.availDevices = [...this.allDevices];
+ this.walDeviceSelectionGroups.devices = [];
+ this.dbDeviceSelectionGroups.devices = [];
+ this.disableFeatures();
+ this.driveGroup.reset();
+ this.form.get('walSlots').setValue(0, { emitEvent: false });
+ this.form.get('dbSlots').setValue(0, { emitEvent: false });
+ } else {
+ this.availDevices = [...this.availDevices, ...event.clearedDevices];
+ this.driveGroup.clearDeviceSelection(event.type);
+ const slotControlName = `${event.type}Slots`;
+ this.form.get(slotControlName).setValue(0, { emitEvent: false });
+ }
+ }
+
+ emitDeploymentSelection() {
+ const option = this.form.get('deploymentOption').value;
+ const encrypted = this.form.get('encrypted').value;
+ this.emitDeploymentOption.emit({ option: option, encrypted: encrypted });
+ }
+
+ emitDeploymentMode() {
+ this.simpleDeployment = !this.simpleDeployment;
+ if (!this.simpleDeployment && this.dataDeviceSelectionGroups.devices.length === 0) {
+ this.disableFeatures();
+ } else {
+ this.enableFeatures();
+ }
+ this.emitMode.emit(this.simpleDeployment);
+ }
+
+ submit() {
+ if (this.simpleDeployment) {
+ const option = this.form.get('deploymentOption').value;
+ const encrypted = this.form.get('encrypted').value;
+ const deploymentSpec = { option: option, encrypted: encrypted };
+ const title = this.deploymentOptions.options[deploymentSpec.option].title;
+ const trackingId = `${title} deployment`;
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('osd/' + URLVerbs.CREATE, {
+ tracking_id: trackingId
+ }),
+ call: this.osdService.create([deploymentSpec], trackingId, 'predefined')
+ })
+ .subscribe({
+ complete: () => {
+ this.router.navigate(['/osd']);
+ }
+ });
+ } else {
+ // use user name and timestamp for drive group name
+ const user = this.authStorageService.getUsername();
+ this.driveGroup.setName(`dashboard-${user}-${_.now()}`);
+ const modalRef = this.modalService.show(OsdCreationPreviewModalComponent, {
+ driveGroups: [this.driveGroup.spec]
+ });
+ modalRef.componentInstance.submitAction.subscribe(() => {
+ this.router.navigate(['/osd']);
+ });
+ this.previewButtonPanel.submitButton.loading = false;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json
new file mode 100644
index 000000000..2de532703
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json
@@ -0,0 +1,605 @@
+[
+ {
+ "osd": 0,
+ "up": 1,
+ "in": 1,
+ "weight": 1.0,
+ "primary_affinity": 1.0,
+ "last_clean_begin": 0,
+ "last_clean_end": 0,
+ "up_from": 8,
+ "up_thru": 143,
+ "down_at": 0,
+ "lost_at": 0,
+ "public_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6802" },
+ { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6803" }
+ ]
+ },
+ "cluster_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6804" },
+ { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6805" }
+ ]
+ },
+ "heartbeat_back_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6808" },
+ { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6809" }
+ ]
+ },
+ "heartbeat_front_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6806" },
+ { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6807" }
+ ]
+ },
+ "state": ["exists", "up"],
+ "uuid": "7fd350c1-ff37-4b89-b4a7-774219e78cbb",
+ "public_addr": "192.168.2.106:6803/9066",
+ "cluster_addr": "192.168.2.106:6805/9066",
+ "heartbeat_back_addr": "192.168.2.106:6809/9066",
+ "heartbeat_front_addr": "192.168.2.106:6807/9066",
+ "id": 0,
+ "osd_stats": {
+ "osd": 0,
+ "up_from": 8,
+ "seq": 34359740004,
+ "num_pgs": 201,
+ "num_osds": 1,
+ "num_per_pool_osds": 1,
+ "num_per_pool_omap_osds": 1,
+ "kb": 105906168,
+ "kb_used": 2099028,
+ "kb_used_data": 1876,
+ "kb_used_omap": 0,
+ "kb_used_meta": 1048576,
+ "kb_avail": 103807140,
+ "statfs": {
+ "total": 108447916032,
+ "available": 106298511360,
+ "internally_reserved": 1073741824,
+ "allocated": 1921024,
+ "data_stored": 748530,
+ "data_compressed": 0,
+ "data_compressed_allocated": 0,
+ "data_compressed_original": 0,
+ "omap_allocated": 0,
+ "internal_metadata": 1073741824
+ },
+ "hb_peers": [1, 2],
+ "snap_trim_queue_len": 0,
+ "num_snap_trimming": 0,
+ "num_shards_repaired": 0,
+ "op_queue_age_hist": { "histogram": [], "upper_bound": 1 },
+ "perf_stat": {
+ "commit_latency_ms": 0.0,
+ "apply_latency_ms": 0.0,
+ "commit_latency_ns": 0,
+ "apply_latency_ns": 0
+ },
+ "alerts": []
+ },
+ "tree": {
+ "id": 0,
+ "device_class": "ssd",
+ "type": "osd",
+ "type_id": 0,
+ "crush_weight": 0.0985870361328125,
+ "depth": 2,
+ "pool_weights": {},
+ "exists": 1,
+ "status": "up",
+ "reweight": 1.0,
+ "primary_affinity": 1.0,
+ "name": "osd.0"
+ },
+ "host": {
+ "id": -3,
+ "name": "ceph-master",
+ "type": "host",
+ "type_id": 1,
+ "pool_weights": {},
+ "children": [2, 1, 0]
+ },
+ "stats": {
+ "op_w": 0.0,
+ "op_in_bytes": 0.0,
+ "op_r": 0.0,
+ "op_out_bytes": 0.0,
+ "numpg": 201,
+ "stat_bytes": 108447916032,
+ "stat_bytes_used": 2149404672
+ },
+ "stats_history": {
+ "op_w": [
+ [1594973071.815675, 0.0],
+ [1594973076.8181818, 0.0],
+ [1594973081.8206801, 0.0],
+ [1594973086.8231986, 0.0],
+ [1594973091.8258255, 0.0],
+ [1594973096.8285067, 0.0],
+ [1594973101.830774, 0.0],
+ [1594973106.8332067, 0.0],
+ [1594973111.8377645, 0.0],
+ [1594973116.8413265, 0.0],
+ [1594973121.8436713, 0.0],
+ [1594973126.846079, 0.0],
+ [1594973131.8485043, 0.0],
+ [1594973136.8509178, 0.0],
+ [1594973141.8532503, 0.0],
+ [1594973146.8557014, 0.0],
+ [1594973151.857818, 0.0],
+ [1594973156.8602881, 0.0],
+ [1594973161.862781, 0.0]
+ ],
+ "op_in_bytes": [
+ [1594973071.815675, 0.0],
+ [1594973076.8181818, 0.0],
+ [1594973081.8206801, 0.0],
+ [1594973086.8231986, 0.0],
+ [1594973091.8258255, 0.0],
+ [1594973096.8285067, 0.0],
+ [1594973101.830774, 0.0],
+ [1594973106.8332067, 0.0],
+ [1594973111.8377645, 0.0],
+ [1594973116.8413265, 0.0],
+ [1594973121.8436713, 0.0],
+ [1594973126.846079, 0.0],
+ [1594973131.8485043, 0.0],
+ [1594973136.8509178, 0.0],
+ [1594973141.8532503, 0.0],
+ [1594973146.8557014, 0.0],
+ [1594973151.857818, 0.0],
+ [1594973156.8602881, 0.0],
+ [1594973161.862781, 0.0]
+ ],
+ "op_r": [
+ [1594973071.815675, 0.0],
+ [1594973076.8181818, 0.0],
+ [1594973081.8206801, 0.0],
+ [1594973086.8231986, 0.0],
+ [1594973091.8258255, 0.0],
+ [1594973096.8285067, 0.0],
+ [1594973101.830774, 0.0],
+ [1594973106.8332067, 0.0],
+ [1594973111.8377645, 0.0],
+ [1594973116.8413265, 0.0],
+ [1594973121.8436713, 0.0],
+ [1594973126.846079, 0.0],
+ [1594973131.8485043, 0.0],
+ [1594973136.8509178, 0.0],
+ [1594973141.8532503, 0.0],
+ [1594973146.8557014, 0.0],
+ [1594973151.857818, 0.0],
+ [1594973156.8602881, 0.0],
+ [1594973161.862781, 0.0]
+ ],
+ "op_out_bytes": [
+ [1594973071.815675, 0.0],
+ [1594973076.8181818, 0.0],
+ [1594973081.8206801, 0.0],
+ [1594973086.8231986, 0.0],
+ [1594973091.8258255, 0.0],
+ [1594973096.8285067, 0.0],
+ [1594973101.830774, 0.0],
+ [1594973106.8332067, 0.0],
+ [1594973111.8377645, 0.0],
+ [1594973116.8413265, 0.0],
+ [1594973121.8436713, 0.0],
+ [1594973126.846079, 0.0],
+ [1594973131.8485043, 0.0],
+ [1594973136.8509178, 0.0],
+ [1594973141.8532503, 0.0],
+ [1594973146.8557014, 0.0],
+ [1594973151.857818, 0.0],
+ [1594973156.8602881, 0.0],
+ [1594973161.862781, 0.0]
+ ]
+ },
+ "operational_status": "working"
+ },
+ {
+ "osd": 1,
+ "up": 1,
+ "in": 1,
+ "weight": 1.0,
+ "primary_affinity": 1.0,
+ "last_clean_begin": 0,
+ "last_clean_end": 0,
+ "up_from": 13,
+ "up_thru": 143,
+ "down_at": 0,
+ "lost_at": 0,
+ "public_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6810" },
+ { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6811" }
+ ]
+ },
+ "cluster_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6812" },
+ { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6813" }
+ ]
+ },
+ "heartbeat_back_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6816" },
+ { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6817" }
+ ]
+ },
+ "heartbeat_front_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6814" },
+ { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6815" }
+ ]
+ },
+ "state": ["exists", "up"],
+ "uuid": "b57436ab-31cf-43ab-ae04-2b1ead69d155",
+ "public_addr": "192.168.2.106:6811/10136",
+ "cluster_addr": "192.168.2.106:6813/10136",
+ "heartbeat_back_addr": "192.168.2.106:6817/10136",
+ "heartbeat_front_addr": "192.168.2.106:6815/10136",
+ "id": 1,
+ "osd_stats": {
+ "osd": 1,
+ "up_from": 13,
+ "seq": 55834576483,
+ "num_pgs": 201,
+ "num_osds": 1,
+ "num_per_pool_osds": 1,
+ "num_per_pool_omap_osds": 1,
+ "kb": 105906168,
+ "kb_used": 2099028,
+ "kb_used_data": 1876,
+ "kb_used_omap": 0,
+ "kb_used_meta": 1048576,
+ "kb_avail": 103807140,
+ "statfs": {
+ "total": 108447916032,
+ "available": 106298511360,
+ "internally_reserved": 1073741824,
+ "allocated": 1921024,
+ "data_stored": 748530,
+ "data_compressed": 0,
+ "data_compressed_allocated": 0,
+ "data_compressed_original": 0,
+ "omap_allocated": 0,
+ "internal_metadata": 1073741824
+ },
+ "hb_peers": [0, 2],
+ "snap_trim_queue_len": 0,
+ "num_snap_trimming": 0,
+ "num_shards_repaired": 0,
+ "op_queue_age_hist": { "histogram": [], "upper_bound": 1 },
+ "perf_stat": {
+ "commit_latency_ms": 0.0,
+ "apply_latency_ms": 0.0,
+ "commit_latency_ns": 0,
+ "apply_latency_ns": 0
+ },
+ "alerts": []
+ },
+ "tree": {
+ "id": 1,
+ "device_class": "ssd",
+ "type": "osd",
+ "type_id": 0,
+ "crush_weight": 0.0985870361328125,
+ "depth": 2,
+ "pool_weights": {},
+ "exists": 1,
+ "status": "up",
+ "reweight": 1.0,
+ "primary_affinity": 1.0,
+ "name": "osd.1"
+ },
+ "host": {
+ "id": -3,
+ "name": "ceph-master",
+ "type": "host",
+ "type_id": 1,
+ "pool_weights": {},
+ "children": [2, 1, 0]
+ },
+ "stats": {
+ "op_w": 0.0,
+ "op_in_bytes": 0.0,
+ "op_r": 0.0,
+ "op_out_bytes": 0.0,
+ "numpg": 201,
+ "stat_bytes": 108447916032,
+ "stat_bytes_used": 2149404672
+ },
+ "stats_history": {
+ "op_w": [
+ [1594973072.2473748, 0.0],
+ [1594973077.249638, 0.0],
+ [1594973082.252127, 0.0],
+ [1594973087.2545457, 0.0],
+ [1594973092.2568345, 0.0],
+ [1594973097.2593641, 0.0],
+ [1594973102.2615848, 0.0],
+ [1594973107.263888, 0.0],
+ [1594973112.2665699, 0.0],
+ [1594973117.2689157, 0.0],
+ [1594973122.2711878, 0.0],
+ [1594973127.2736654, 0.0],
+ [1594973132.2760675, 0.0],
+ [1594973137.2787013, 0.0],
+ [1594973142.2811794, 0.0],
+ [1594973147.2834256, 0.0],
+ [1594973152.2856195, 0.0],
+ [1594973157.288044, 0.0],
+ [1594973162.2904015, 0.0]
+ ],
+ "op_in_bytes": [
+ [1594973072.2473748, 0.0],
+ [1594973077.249638, 0.0],
+ [1594973082.252127, 0.0],
+ [1594973087.2545457, 0.0],
+ [1594973092.2568345, 0.0],
+ [1594973097.2593641, 0.0],
+ [1594973102.2615848, 0.0],
+ [1594973107.263888, 0.0],
+ [1594973112.2665699, 0.0],
+ [1594973117.2689157, 0.0],
+ [1594973122.2711878, 0.0],
+ [1594973127.2736654, 0.0],
+ [1594973132.2760675, 0.0],
+ [1594973137.2787013, 0.0],
+ [1594973142.2811794, 0.0],
+ [1594973147.2834256, 0.0],
+ [1594973152.2856195, 0.0],
+ [1594973157.288044, 0.0],
+ [1594973162.2904015, 0.0]
+ ],
+ "op_r": [
+ [1594973072.2473748, 0.0],
+ [1594973077.249638, 0.0],
+ [1594973082.252127, 0.0],
+ [1594973087.2545457, 0.0],
+ [1594973092.2568345, 0.0],
+ [1594973097.2593641, 0.0],
+ [1594973102.2615848, 0.0],
+ [1594973107.263888, 0.0],
+ [1594973112.2665699, 0.0],
+ [1594973117.2689157, 0.0],
+ [1594973122.2711878, 0.0],
+ [1594973127.2736654, 0.0],
+ [1594973132.2760675, 0.0],
+ [1594973137.2787013, 0.0],
+ [1594973142.2811794, 0.0],
+ [1594973147.2834256, 0.0],
+ [1594973152.2856195, 0.0],
+ [1594973157.288044, 0.0],
+ [1594973162.2904015, 0.0]
+ ],
+ "op_out_bytes": [
+ [1594973072.2473748, 0.0],
+ [1594973077.249638, 0.0],
+ [1594973082.252127, 0.0],
+ [1594973087.2545457, 0.0],
+ [1594973092.2568345, 0.0],
+ [1594973097.2593641, 0.0],
+ [1594973102.2615848, 0.0],
+ [1594973107.263888, 0.0],
+ [1594973112.2665699, 0.0],
+ [1594973117.2689157, 0.0],
+ [1594973122.2711878, 0.0],
+ [1594973127.2736654, 0.0],
+ [1594973132.2760675, 0.0],
+ [1594973137.2787013, 0.0],
+ [1594973142.2811794, 0.0],
+ [1594973147.2834256, 0.0],
+ [1594973152.2856195, 0.0],
+ [1594973157.288044, 0.0],
+ [1594973162.2904015, 0.0]
+ ]
+ },
+ "operational_status": "unmanaged"
+ },
+ {
+ "osd": 2,
+ "up": 1,
+ "in": 1,
+ "weight": 1.0,
+ "primary_affinity": 1.0,
+ "last_clean_begin": 0,
+ "last_clean_end": 0,
+ "up_from": 17,
+ "up_thru": 143,
+ "down_at": 0,
+ "lost_at": 0,
+ "public_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6818" },
+ { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6819" }
+ ]
+ },
+ "cluster_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6820" },
+ { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6821" }
+ ]
+ },
+ "heartbeat_back_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6824" },
+ { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6825" }
+ ]
+ },
+ "heartbeat_front_addrs": {
+ "addrvec": [
+ { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6822" },
+ { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6823" }
+ ]
+ },
+ "state": ["exists", "up"],
+ "uuid": "6e6b88e3-67aa-4ea0-aac0-cbfe89a0f652",
+ "public_addr": "192.168.2.106:6819/11208",
+ "cluster_addr": "192.168.2.106:6821/11208",
+ "heartbeat_back_addr": "192.168.2.106:6825/11208",
+ "heartbeat_front_addr": "192.168.2.106:6823/11208",
+ "id": 2,
+ "osd_stats": {
+ "osd": 2,
+ "up_from": 17,
+ "seq": 73014445666,
+ "num_pgs": 201,
+ "num_osds": 1,
+ "num_per_pool_osds": 1,
+ "num_per_pool_omap_osds": 1,
+ "kb": 105906168,
+ "kb_used": 2099028,
+ "kb_used_data": 1876,
+ "kb_used_omap": 0,
+ "kb_used_meta": 1048576,
+ "kb_avail": 103807140,
+ "statfs": {
+ "total": 108447916032,
+ "available": 106298511360,
+ "internally_reserved": 1073741824,
+ "allocated": 1921024,
+ "data_stored": 748530,
+ "data_compressed": 0,
+ "data_compressed_allocated": 0,
+ "data_compressed_original": 0,
+ "omap_allocated": 0,
+ "internal_metadata": 1073741824
+ },
+ "hb_peers": [0, 1],
+ "snap_trim_queue_len": 0,
+ "num_snap_trimming": 0,
+ "num_shards_repaired": 0,
+ "op_queue_age_hist": { "histogram": [], "upper_bound": 1 },
+ "perf_stat": {
+ "commit_latency_ms": 0.0,
+ "apply_latency_ms": 0.0,
+ "commit_latency_ns": 0,
+ "apply_latency_ns": 0
+ },
+ "alerts": []
+ },
+ "tree": {
+ "id": 2,
+ "device_class": "ssd",
+ "type": "osd",
+ "type_id": 0,
+ "crush_weight": 0.0985870361328125,
+ "depth": 2,
+ "pool_weights": {},
+ "exists": 1,
+ "status": "up",
+ "reweight": 1.0,
+ "primary_affinity": 1.0,
+ "name": "osd.2"
+ },
+ "host": {
+ "id": -3,
+ "name": "ceph-master",
+ "type": "host",
+ "type_id": 1,
+ "pool_weights": {},
+ "children": [2, 1, 0]
+ },
+ "stats": {
+ "op_w": 0.0,
+ "op_in_bytes": 0.0,
+ "op_r": 0.0,
+ "op_out_bytes": 0.0,
+ "numpg": 201,
+ "stat_bytes": 108447916032,
+ "stat_bytes_used": 2149404672
+ },
+ "stats_history": {
+ "op_w": [
+ [1594973071.7967167, 0.0],
+ [1594973076.7992308, 0.0],
+ [1594973081.8016157, 0.0],
+ [1594973086.8038485, 0.0],
+ [1594973091.806146, 0.0],
+ [1594973096.8079553, 0.0],
+ [1594973101.8099923, 0.0],
+ [1594973106.8122191, 0.0],
+ [1594973111.814509, 0.0],
+ [1594973116.8168204, 0.0],
+ [1594973121.8191206, 0.0],
+ [1594973126.8215034, 0.0],
+ [1594973131.8238406, 0.0],
+ [1594973136.8261213, 0.0],
+ [1594973141.8283849, 0.0],
+ [1594973146.8305933, 0.0],
+ [1594973151.8342226, 0.0],
+ [1594973156.837437, 0.0],
+ [1594973161.8397536, 0.0]
+ ],
+ "op_in_bytes": [
+ [1594973071.7967167, 0.0],
+ [1594973076.7992308, 0.0],
+ [1594973081.8016157, 0.0],
+ [1594973086.8038485, 0.0],
+ [1594973091.806146, 0.0],
+ [1594973096.8079553, 0.0],
+ [1594973101.8099923, 0.0],
+ [1594973106.8122191, 0.0],
+ [1594973111.814509, 0.0],
+ [1594973116.8168204, 0.0],
+ [1594973121.8191206, 0.0],
+ [1594973126.8215034, 0.0],
+ [1594973131.8238406, 0.0],
+ [1594973136.8261213, 0.0],
+ [1594973141.8283849, 0.0],
+ [1594973146.8305933, 0.0],
+ [1594973151.8342226, 0.0],
+ [1594973156.837437, 0.0],
+ [1594973161.8397536, 0.0]
+ ],
+ "op_r": [
+ [1594973071.7967167, 0.0],
+ [1594973076.7992308, 0.0],
+ [1594973081.8016157, 0.0],
+ [1594973086.8038485, 0.0],
+ [1594973091.806146, 0.0],
+ [1594973096.8079553, 0.0],
+ [1594973101.8099923, 0.0],
+ [1594973106.8122191, 0.0],
+ [1594973111.814509, 0.0],
+ [1594973116.8168204, 0.0],
+ [1594973121.8191206, 0.0],
+ [1594973126.8215034, 0.0],
+ [1594973131.8238406, 0.0],
+ [1594973136.8261213, 0.0],
+ [1594973141.8283849, 0.0],
+ [1594973146.8305933, 0.0],
+ [1594973151.8342226, 0.0],
+ [1594973156.837437, 0.0],
+ [1594973161.8397536, 0.0]
+ ],
+ "op_out_bytes": [
+ [1594973071.7967167, 0.0],
+ [1594973076.7992308, 0.0],
+ [1594973081.8016157, 0.0],
+ [1594973086.8038485, 0.0],
+ [1594973091.806146, 0.0],
+ [1594973096.8079553, 0.0],
+ [1594973101.8099923, 0.0],
+ [1594973106.8122191, 0.0],
+ [1594973111.814509, 0.0],
+ [1594973116.8168204, 0.0],
+ [1594973121.8191206, 0.0],
+ [1594973126.8215034, 0.0],
+ [1594973131.8238406, 0.0],
+ [1594973136.8261213, 0.0],
+ [1594973141.8283849, 0.0],
+ [1594973146.8305933, 0.0],
+ [1594973151.8342226, 0.0],
+ [1594973156.837437, 0.0],
+ [1594973161.8397536, 0.0]
+ ]
+ },
+ "operational_status": "deleting"
+ }
+]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html
new file mode 100644
index 000000000..c1c1894d7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html
@@ -0,0 +1,150 @@
+<ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem>
+ <a ngbNavLink
+ i18n>OSDs List</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="osds"
+ (fetchData)="getOsdList()"
+ [columns]="columns"
+ selectionType="multiClick"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)"
+ [updateSelectionOnRefresh]="'never'">
+
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions [permission]="permissions.osd"
+ [selection]="selection"
+ class="btn-group"
+ id="osd-actions"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-table-actions [permission]="{read: true}"
+ [selection]="selection"
+ dropDownOnly="Cluster-wide configuration"
+ btnColor="light"
+ class="btn-group"
+ id="cluster-wide-actions"
+ [tableActions]="clusterWideActions">
+ </cd-table-actions>
+ </div>
+
+ <cd-osd-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-osd-details>
+ </cd-table>
+ </ng-template>
+ </li>
+
+ <li ngbNavItem
+ *ngIf="permissions.grafana.read">
+ <a ngbNavLink
+ i18n>Overall Performance</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'osd-overview?'"
+ uid="lo02I1Aiz"
+ grafanaStyle="four">
+ </cd-grafana>
+ </ng-template>
+ </li>
+</ul>
+
+<div [ngbNavOutlet]="nav"></div>
+
+<ng-template #markOsdConfirmationTpl
+ let-markActionDescription="markActionDescription"
+ let-osdIds="osdIds">
+ <ng-container i18n><strong>OSD(s) {{ osdIds | join }}</strong> will be marked
+ <strong>{{ markActionDescription }}</strong> if you proceed.</ng-container>
+</ng-template>
+
+<ng-template #criticalConfirmationTpl
+ let-safeToPerform="safeToPerform"
+ let-message="message"
+ let-active="active"
+ let-missingStats="missingStats"
+ let-storedPgs="storedPgs"
+ let-actionDescription="actionDescription"
+ let-osdIds="osdIds">
+ <div *ngIf="!safeToPerform"
+ class="danger mb-3">
+ <cd-alert-panel type="warning">
+ <span i18n>
+ The {selection.hasSingleSelection, select, true {OSD is} other {OSDs are}} not safe to be
+ {{ actionDescription }}!
+ </span>
+ <br>
+ <ul class="mb-0 pl-4">
+ <li *ngIf="active.length > 0"
+ i18n>
+ {selection.hasSingleSelection, select, true {} other {{{ active | join }} : }}
+ Some PGs are currently mapped to
+ {active.length === 1, select, true {it} other {them}}.
+ </li>
+ <li *ngIf="missingStats.length > 0"
+ i18n>
+ {selection.hasSingleSelection, select, true {} other {{{ missingStats | join }} : }}
+ There are no reported stats and not all PGs are active and clean.
+ </li>
+ <li *ngIf="storedPgs.length > 0"
+ i18n>
+ {selection.hasSingleSelection, select, true {OSD} other {{{ storedPgs | join }} : OSDs }}
+ still store some PG data and not all PGs are active and clean.
+ </li>
+ <li *ngIf="message">
+ {{ message }}
+ </li>
+ </ul>
+ </cd-alert-panel>
+ </div>
+ <div *ngIf="safeToPerform"
+ class="danger mb-3">
+ <cd-alert-panel type="info">
+ <span i18n>
+ The {selection.hasSingleSelection, select, true {OSD is} other {OSDs are}}
+ safe to destroy without reducing data durability.
+ </span>
+ </cd-alert-panel>
+ </div>
+ <ng-container i18n><strong>OSD {{ osdIds | join }}</strong> will be
+ <strong>{{ actionDescription }}</strong> if you proceed.</ng-container>
+</ng-template>
+
+<ng-template #flagsTpl
+ let-row="row">
+ <span *ngFor="let flag of row.cdClusterFlags;"
+ class="badge badge-hdd mr-1">{{ flag }}</span>
+ <span *ngFor="let flag of row.cdIndivFlags;"
+ class="badge badge-info mr-1">{{ flag }}</span>
+</ng-template>
+
+<ng-template #osdUsageTpl
+ let-row="row">
+ <cd-usage-bar [total]="row.stats.stat_bytes"
+ [used]="row.stats.stat_bytes_used"
+ [warningThreshold]="osdSettings.nearfull_ratio"
+ [errorThreshold]="osdSettings.full_ratio">
+ </cd-usage-bar>
+</ng-template>
+
+<ng-template #deleteOsdExtraTpl
+ let-form="form">
+ <ng-container [formGroup]="form">
+ <ng-container formGroupName="child">
+ <div class="form-group">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ name="preserve"
+ id="preserve"
+ formControlName="preserve">
+ <label class="custom-control-label"
+ for="preserve"
+ i18n>Preserve OSD ID(s) for replacement.</label>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts
new file mode 100644
index 000000000..d6f865471
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts
@@ -0,0 +1,641 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { EMPTY, of } from 'rxjs';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { PerformanceCounterModule } from '~/app/ceph/performance-counter/performance-counter.module';
+import { CoreModule } from '~/app/core/core.module';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+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 { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import {
+ configureTestBed,
+ OrchestratorHelper,
+ PermissionHelper,
+ TableActionHelper
+} from '~/testing/unit-test-helper';
+import { OsdReweightModalComponent } from '../osd-reweight-modal/osd-reweight-modal.component';
+import { OsdListComponent } from './osd-list.component';
+
+describe('OsdListComponent', () => {
+ let component: OsdListComponent;
+ let fixture: ComponentFixture<OsdListComponent>;
+ let modalServiceShowSpy: jasmine.Spy;
+ let osdService: OsdService;
+ let orchService: OrchestratorService;
+
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({
+ 'config-opt': ['read', 'update', 'create', 'delete'],
+ osd: ['read', 'update', 'create', 'delete']
+ });
+ }
+ };
+
+ const getTableAction = (name: string) =>
+ component.tableActions.find((action) => action.name === name);
+
+ const setFakeSelection = () => {
+ // Default data and selection
+ const selection = [{ id: 1, tree: { device_class: 'ssd' } }];
+ const data = [{ id: 1, tree: { device_class: 'ssd' } }];
+
+ // Table data and selection
+ component.selection = new CdTableSelection();
+ component.selection.selected = selection;
+ component.osds = data;
+ component.permissions = fakeAuthStorageService.getPermissions();
+ };
+
+ const openActionModal = (actionName: string) => {
+ setFakeSelection();
+ getTableAction(actionName).click();
+ };
+
+ /**
+ * The following modals are called after the information about their
+ * safety to destroy/remove/mark them lost has been retrieved, hence
+ * we will have to fake its request to be able to open those modals.
+ */
+ const mockSafeToDestroy = () => {
+ spyOn(TestBed.inject(OsdService), 'safeToDestroy').and.callFake(() =>
+ of({ is_safe_to_destroy: true })
+ );
+ };
+
+ const mockSafeToDelete = () => {
+ spyOn(TestBed.inject(OsdService), 'safeToDelete').and.callFake(() =>
+ of({ is_safe_to_delete: true })
+ );
+ };
+
+ const mockOrch = () => {
+ const features = [OrchestratorFeature.OSD_CREATE, OrchestratorFeature.OSD_DELETE];
+ OrchestratorHelper.mockStatus(true, features);
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ PerformanceCounterModule,
+ ToastrModule.forRoot(),
+ CephModule,
+ ReactiveFormsModule,
+ NgbDropdownModule,
+ RouterTestingModule,
+ CoreModule,
+ RouterTestingModule
+ ],
+ providers: [
+ { provide: AuthStorageService, useValue: fakeAuthStorageService },
+ TableActionsComponent,
+ ModalService
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdListComponent);
+ component = fixture.componentInstance;
+ osdService = TestBed.inject(OsdService);
+ modalServiceShowSpy = spyOn(TestBed.inject(ModalService), 'show').and.returnValue({
+ // mock the close function, it might be called if there are async tests.
+ close: jest.fn()
+ });
+ orchService = TestBed.inject(OrchestratorService);
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should have columns that are sortable', () => {
+ fixture.detectChanges();
+ expect(
+ component.columns
+ .filter((column) => !(column.prop === undefined))
+ .every((column) => Boolean(column.prop))
+ ).toBeTruthy();
+ });
+
+ describe('getOsdList', () => {
+ let osds: any[];
+ let flagsSpy: jasmine.Spy;
+
+ const createOsd = (n: number) =>
+ <Record<string, any>>{
+ in: 'in',
+ up: 'up',
+ tree: {
+ device_class: 'ssd'
+ },
+ stats_history: {
+ op_out_bytes: [
+ [n, n],
+ [n * 2, n * 2]
+ ],
+ op_in_bytes: [
+ [n * 3, n * 3],
+ [n * 4, n * 4]
+ ]
+ },
+ stats: {
+ stat_bytes_used: n * n,
+ stat_bytes: n * n * n
+ },
+ state: []
+ };
+
+ const expectAttributeOnEveryOsd = (attr: string) =>
+ expect(component.osds.every((osd) => Boolean(_.get(osd, attr)))).toBeTruthy();
+
+ beforeEach(() => {
+ spyOn(osdService, 'getList').and.callFake(() => of(osds));
+ flagsSpy = spyOn(osdService, 'getFlags').and.callFake(() => of([]));
+ osds = [createOsd(1), createOsd(2), createOsd(3)];
+ component.getOsdList();
+ });
+
+ it('should replace "this.osds" with new data', () => {
+ expect(component.osds.length).toBe(3);
+ expect(osdService.getList).toHaveBeenCalledTimes(1);
+
+ osds = [createOsd(4)];
+ component.getOsdList();
+ expect(component.osds.length).toBe(1);
+ expect(osdService.getList).toHaveBeenCalledTimes(2);
+ });
+
+ it('should have custom attribute "collectedStates"', () => {
+ expectAttributeOnEveryOsd('collectedStates');
+ expect(component.osds[0].collectedStates).toEqual(['in', 'up']);
+ });
+
+ it('should have "destroyed" state in "collectedStates"', () => {
+ osds[0].state.push('destroyed');
+ osds[0].up = 0;
+ component.getOsdList();
+
+ expectAttributeOnEveryOsd('collectedStates');
+ expect(component.osds[0].collectedStates).toEqual(['in', 'destroyed']);
+ });
+
+ it('should have custom attribute "stats_history.out_bytes"', () => {
+ expectAttributeOnEveryOsd('stats_history.out_bytes');
+ expect(component.osds[0].stats_history.out_bytes).toEqual([1, 2]);
+ });
+
+ it('should have custom attribute "stats_history.in_bytes"', () => {
+ expectAttributeOnEveryOsd('stats_history.in_bytes');
+ expect(component.osds[0].stats_history.in_bytes).toEqual([3, 4]);
+ });
+
+ it('should have custom attribute "stats.usage"', () => {
+ expectAttributeOnEveryOsd('stats.usage');
+ expect(component.osds[0].stats.usage).toBe(1);
+ expect(component.osds[1].stats.usage).toBe(0.5);
+ expect(component.osds[2].stats.usage).toBe(3 / 9);
+ });
+
+ it('should have custom attribute "cdIsBinary" to be true', () => {
+ expectAttributeOnEveryOsd('cdIsBinary');
+ expect(component.osds[0].cdIsBinary).toBe(true);
+ });
+
+ it('should return valid individual flags only', () => {
+ const osd1 = createOsd(1);
+ const osd2 = createOsd(2);
+ osd1.state = ['noup', 'exists', 'up'];
+ osd2.state = ['noup', 'exists', 'up', 'noin'];
+ osds = [osd1, osd2];
+ component.getOsdList();
+
+ expect(component.osds[0].cdIndivFlags).toStrictEqual(['noup']);
+ expect(component.osds[1].cdIndivFlags).toStrictEqual(['noup', 'noin']);
+ });
+
+ it('should not fail on empty individual flags list', () => {
+ expect(component.osds[0].cdIndivFlags).toStrictEqual([]);
+ });
+
+ it('should not return disabled cluster-wide flags', () => {
+ flagsSpy.and.callFake(() => of(['noout', 'nodown', 'sortbitwise']));
+ component.getOsdList();
+ expect(component.osds[0].cdClusterFlags).toStrictEqual(['noout', 'nodown']);
+
+ flagsSpy.and.callFake(() => of(['noout', 'purged_snapdirs', 'nodown']));
+ component.getOsdList();
+ expect(component.osds[0].cdClusterFlags).toStrictEqual(['noout', 'nodown']);
+
+ flagsSpy.and.callFake(() => of(['recovery_deletes', 'noout', 'pglog_hardlimit', 'nodown']));
+ component.getOsdList();
+ expect(component.osds[0].cdClusterFlags).toStrictEqual(['noout', 'nodown']);
+ });
+
+ it('should not fail on empty cluster-wide flags list', () => {
+ flagsSpy.and.callFake(() => of([]));
+ component.getOsdList();
+ expect(component.osds[0].cdClusterFlags).toStrictEqual([]);
+ });
+
+ it('should have custom attribute "cdExecuting"', () => {
+ osds[1].operational_status = 'unmanaged';
+ osds[2].operational_status = 'deleting';
+ component.getOsdList();
+ expect(component.osds[0].cdExecuting).toBeUndefined();
+ expect(component.osds[1].cdExecuting).toBeUndefined();
+ expect(component.osds[2].cdExecuting).toBe('deleting');
+ });
+ });
+
+ describe('show osd actions as defined', () => {
+ const getOsdActions = () => {
+ fixture.detectChanges();
+ return fixture.debugElement.query(By.css('#cluster-wide-actions')).componentInstance
+ .dropDownActions;
+ };
+
+ it('shows osd actions after osd-actions', () => {
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('#cluster-wide-actions'))).toBe(
+ fixture.debugElement.queryAll(By.directive(TableActionsComponent))[1]
+ );
+ });
+
+ it('shows both osd actions', () => {
+ const osdActions = getOsdActions();
+ expect(osdActions).toEqual(component.clusterWideActions);
+ expect(osdActions.length).toBe(3);
+ });
+
+ it('shows only "Flags" action', () => {
+ component.permissions.configOpt.read = false;
+ const osdActions = getOsdActions();
+ expect(osdActions[0].name).toBe('Flags');
+ expect(osdActions.length).toBe(1);
+ });
+
+ it('shows only "Recovery Priority" action', () => {
+ component.permissions.osd.read = false;
+ const osdActions = getOsdActions();
+ expect(osdActions[0].name).toBe('Recovery Priority');
+ expect(osdActions[1].name).toBe('PG scrub');
+ expect(osdActions.length).toBe(2);
+ });
+
+ it('shows no osd actions', () => {
+ component.permissions.configOpt.read = false;
+ component.permissions.osd.read = false;
+ const osdActions = getOsdActions();
+ expect(osdActions).toEqual([]);
+ });
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permissions.osd);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: [
+ 'Create',
+ 'Edit',
+ 'Flags',
+ 'Scrub',
+ 'Deep Scrub',
+ 'Reweight',
+ 'Mark Out',
+ 'Mark In',
+ 'Mark Down',
+ 'Mark Lost',
+ 'Purge',
+ 'Destroy',
+ 'Delete'
+ ],
+ primary: { multiple: 'Scrub', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: [
+ 'Create',
+ 'Edit',
+ 'Flags',
+ 'Scrub',
+ 'Deep Scrub',
+ 'Reweight',
+ 'Mark Out',
+ 'Mark In',
+ 'Mark Down'
+ ],
+ primary: { multiple: 'Scrub', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Mark Lost', 'Purge', 'Destroy', 'Delete'],
+ primary: {
+ multiple: 'Create',
+ executing: 'Mark Lost',
+ single: 'Mark Lost',
+ no: 'Create'
+ }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: [
+ 'Edit',
+ 'Flags',
+ 'Scrub',
+ 'Deep Scrub',
+ 'Reweight',
+ 'Mark Out',
+ 'Mark In',
+ 'Mark Down',
+ 'Mark Lost',
+ 'Purge',
+ 'Destroy',
+ 'Delete'
+ ],
+ primary: { multiple: 'Scrub', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: [
+ 'Edit',
+ 'Flags',
+ 'Scrub',
+ 'Deep Scrub',
+ 'Reweight',
+ 'Mark Out',
+ 'Mark In',
+ 'Mark Down'
+ ],
+ primary: { multiple: 'Scrub', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Mark Lost', 'Purge', 'Destroy', 'Delete'],
+ primary: {
+ multiple: 'Mark Lost',
+ executing: 'Mark Lost',
+ single: 'Mark Lost',
+ no: 'Mark Lost'
+ }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ describe('test table actions in submenu', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ beforeEach(fakeAsync(() => {
+ // The menu needs a click to render the dropdown!
+ const dropDownToggle = fixture.debugElement.query(By.css('.dropdown-toggle'));
+ dropDownToggle.triggerEventHandler('click', null);
+ tick();
+ fixture.detectChanges();
+ }));
+
+ it('has all menu entries disabled except create', () => {
+ const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
+ const toClassName = TestBed.inject(TableActionsComponent).toClassName;
+ const getActionClasses = (action: CdTableAction) =>
+ tableActionElement.query(By.css(`[ngbDropdownItem].${toClassName(action)}`)).classes;
+
+ component.tableActions.forEach((action) => {
+ if (action.name === 'Create') {
+ return;
+ }
+ expect(getActionClasses(action).disabled).toBe(true);
+ });
+ });
+ });
+
+ describe('tests if all modals are opened correctly', () => {
+ /**
+ * Helper function to check if a function opens a modal
+ *
+ * @param modalClass - The expected class of the modal
+ */
+ const expectOpensModal = (actionName: string, modalClass: any): void => {
+ openActionModal(actionName);
+
+ // @TODO: check why tsc is complaining when passing 'expectationFailOutput' param.
+ expect(modalServiceShowSpy.calls.any()).toBeTruthy();
+ expect(modalServiceShowSpy.calls.first().args[0]).toBe(modalClass);
+
+ modalServiceShowSpy.calls.reset();
+ };
+
+ it('opens the reweight modal', () => {
+ expectOpensModal('Reweight', OsdReweightModalComponent);
+ });
+
+ it('opens the form modal', () => {
+ expectOpensModal('Edit', FormModalComponent);
+ });
+
+ it('opens all confirmation modals', () => {
+ const modalClass = ConfirmationModalComponent;
+ expectOpensModal('Mark Out', modalClass);
+ expectOpensModal('Mark In', modalClass);
+ expectOpensModal('Mark Down', modalClass);
+ });
+
+ it('opens all critical confirmation modals', () => {
+ const modalClass = CriticalConfirmationModalComponent;
+ mockSafeToDestroy();
+ expectOpensModal('Mark Lost', modalClass);
+ expectOpensModal('Purge', modalClass);
+ expectOpensModal('Destroy', modalClass);
+ mockOrch();
+ mockSafeToDelete();
+ expectOpensModal('Delete', modalClass);
+ });
+ });
+
+ describe('tests if the correct methods are called on confirmation', () => {
+ const expectOsdServiceMethodCalled = (
+ actionName: string,
+ osdServiceMethodName:
+ | 'markOut'
+ | 'markIn'
+ | 'markDown'
+ | 'markLost'
+ | 'purge'
+ | 'destroy'
+ | 'delete'
+ ): void => {
+ const osdServiceSpy = spyOn(osdService, osdServiceMethodName).and.callFake(() => EMPTY);
+ openActionModal(actionName);
+ const initialState = modalServiceShowSpy.calls.first().args[1];
+ const submit = initialState.onSubmit || initialState.submitAction;
+ submit.call(component);
+
+ expect(osdServiceSpy.calls.count()).toBe(1);
+ expect(osdServiceSpy.calls.first().args[0]).toBe(1);
+
+ // Reset spies to be able to recreate them
+ osdServiceSpy.calls.reset();
+ modalServiceShowSpy.calls.reset();
+ };
+
+ it('calls the corresponding service methods in confirmation modals', () => {
+ expectOsdServiceMethodCalled('Mark Out', 'markOut');
+ expectOsdServiceMethodCalled('Mark In', 'markIn');
+ expectOsdServiceMethodCalled('Mark Down', 'markDown');
+ });
+
+ it('calls the corresponding service methods in critical confirmation modals', () => {
+ mockSafeToDestroy();
+ expectOsdServiceMethodCalled('Mark Lost', 'markLost');
+ expectOsdServiceMethodCalled('Purge', 'purge');
+ expectOsdServiceMethodCalled('Destroy', 'destroy');
+ mockOrch();
+ mockSafeToDelete();
+ expectOsdServiceMethodCalled('Delete', 'delete');
+ });
+ });
+
+ describe('table actions', () => {
+ const fakeOsds = require('./fixtures/osd_list_response.json');
+
+ beforeEach(() => {
+ component.permissions = fakeAuthStorageService.getPermissions();
+ spyOn(osdService, 'getList').and.callFake(() => of(fakeOsds));
+ spyOn(osdService, 'getFlags').and.callFake(() => of([]));
+ });
+
+ const testTableActions = async (
+ orch: boolean,
+ features: OrchestratorFeature[],
+ tests: { selectRow?: number; expectResults: any }[]
+ ) => {
+ OrchestratorHelper.mockStatus(orch, features);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ for (const test of tests) {
+ if (test.selectRow) {
+ component.selection = new CdTableSelection();
+ component.selection.selected = [test.selectRow];
+ }
+ await TableActionHelper.verifyTableActions(
+ fixture,
+ component.tableActions,
+ test.expectResults
+ );
+ }
+ };
+
+ it('should have correct states when Orchestrator is enabled', async () => {
+ const tests = [
+ {
+ expectResults: {
+ Create: { disabled: false, disableDesc: '' },
+ Delete: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeOsds[0],
+ expectResults: {
+ Create: { disabled: false, disableDesc: '' },
+ Delete: { disabled: false, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeOsds[1], // Select a row that is not managed.
+ expectResults: {
+ Create: { disabled: false, disableDesc: '' },
+ Delete: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeOsds[2], // Select a row that is being deleted.
+ expectResults: {
+ Create: { disabled: false, disableDesc: '' },
+ Delete: { disabled: true, disableDesc: '' }
+ }
+ }
+ ];
+
+ const features = [
+ OrchestratorFeature.OSD_CREATE,
+ OrchestratorFeature.OSD_DELETE,
+ OrchestratorFeature.OSD_GET_REMOVE_STATUS
+ ];
+ await testTableActions(true, features, tests);
+ });
+
+ it('should have correct states when Orchestrator is disabled', async () => {
+ const resultNoOrchestrator = {
+ disabled: true,
+ disableDesc: orchService.disableMessages.noOrchestrator
+ };
+ const tests = [
+ {
+ expectResults: {
+ Create: resultNoOrchestrator,
+ Delete: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeOsds[0],
+ expectResults: {
+ Create: resultNoOrchestrator,
+ Delete: resultNoOrchestrator
+ }
+ }
+ ];
+ await testTableActions(false, [], tests);
+ });
+
+ it('should have correct states when Orchestrator features are missing', async () => {
+ const resultMissingFeatures = {
+ disabled: true,
+ disableDesc: orchService.disableMessages.missingFeature
+ };
+ const tests = [
+ {
+ expectResults: {
+ Create: resultMissingFeatures,
+ Delete: { disabled: true, disableDesc: '' }
+ }
+ },
+ {
+ selectRow: fakeOsds[0],
+ expectResults: {
+ Create: resultMissingFeatures,
+ Delete: resultMissingFeatures
+ }
+ }
+ ];
+ await testTableActions(true, [], tests);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts
new file mode 100644
index 000000000..ec8268d8b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts
@@ -0,0 +1,624 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { forkJoin as observableForkJoin, Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
+
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+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 { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { OsdSettings } from '~/app/shared/models/osd-settings';
+import { Permissions } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { OsdFlagsIndivModalComponent } from '../osd-flags-indiv-modal/osd-flags-indiv-modal.component';
+import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component';
+import { OsdPgScrubModalComponent } from '../osd-pg-scrub-modal/osd-pg-scrub-modal.component';
+import { OsdRecvSpeedModalComponent } from '../osd-recv-speed-modal/osd-recv-speed-modal.component';
+import { OsdReweightModalComponent } from '../osd-reweight-modal/osd-reweight-modal.component';
+import { OsdScrubModalComponent } from '../osd-scrub-modal/osd-scrub-modal.component';
+
+const BASE_URL = 'osd';
+
+@Component({
+ selector: 'cd-osd-list',
+ templateUrl: './osd-list.component.html',
+ styleUrls: ['./osd-list.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class OsdListComponent extends ListWithDetails implements OnInit {
+ @ViewChild('osdUsageTpl', { static: true })
+ osdUsageTpl: TemplateRef<any>;
+ @ViewChild('markOsdConfirmationTpl', { static: true })
+ markOsdConfirmationTpl: TemplateRef<any>;
+ @ViewChild('criticalConfirmationTpl', { static: true })
+ criticalConfirmationTpl: TemplateRef<any>;
+ @ViewChild('reweightBodyTpl')
+ reweightBodyTpl: TemplateRef<any>;
+ @ViewChild('safeToDestroyBodyTpl')
+ safeToDestroyBodyTpl: TemplateRef<any>;
+ @ViewChild('deleteOsdExtraTpl')
+ deleteOsdExtraTpl: TemplateRef<any>;
+ @ViewChild('flagsTpl', { static: true })
+ flagsTpl: TemplateRef<any>;
+
+ permissions: Permissions;
+ tableActions: CdTableAction[];
+ bsModalRef: NgbModalRef;
+ columns: CdTableColumn[];
+ clusterWideActions: CdTableAction[];
+ icons = Icons;
+ osdSettings = new OsdSettings();
+
+ selection = new CdTableSelection();
+ osds: any[] = [];
+ disabledFlags: string[] = [
+ 'sortbitwise',
+ 'purged_snapdirs',
+ 'recovery_deletes',
+ 'pglog_hardlimit'
+ ];
+ indivFlagNames: string[] = ['noup', 'nodown', 'noin', 'noout'];
+
+ orchStatus: OrchestratorStatus;
+ actionOrchFeatures = {
+ create: [OrchestratorFeature.OSD_CREATE],
+ delete: [OrchestratorFeature.OSD_DELETE]
+ };
+
+ protected static collectStates(osd: any) {
+ const states = [osd['in'] ? 'in' : 'out'];
+ if (osd['up']) {
+ states.push('up');
+ } else if (osd.state.includes('destroyed')) {
+ states.push('destroyed');
+ } else {
+ states.push('down');
+ }
+ return states;
+ }
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private osdService: OsdService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private modalService: ModalService,
+ private urlBuilder: URLBuilderService,
+ private router: Router,
+ private taskWrapper: TaskWrapperService,
+ public actionLabels: ActionLabelsI18n,
+ public notificationService: NotificationService,
+ private orchService: OrchestratorService
+ ) {
+ 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),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.editAction()
+ },
+ {
+ name: this.actionLabels.FLAGS,
+ permission: 'update',
+ icon: Icons.flag,
+ click: () => this.configureFlagsIndivAction(),
+ disable: () => !this.hasOsdSelected
+ },
+ {
+ name: this.actionLabels.SCRUB,
+ permission: 'update',
+ icon: Icons.analyse,
+ click: () => this.scrubAction(false),
+ disable: () => !this.hasOsdSelected,
+ canBePrimary: (selection: CdTableSelection) => selection.hasSelection
+ },
+ {
+ name: this.actionLabels.DEEP_SCRUB,
+ permission: 'update',
+ icon: Icons.deepCheck,
+ click: () => this.scrubAction(true),
+ disable: () => !this.hasOsdSelected
+ },
+ {
+ name: this.actionLabels.REWEIGHT,
+ permission: 'update',
+ click: () => this.reweight(),
+ disable: () => !this.hasOsdSelected || !this.selection.hasSingleSelection,
+ icon: Icons.reweight
+ },
+ {
+ name: this.actionLabels.MARK_OUT,
+ permission: 'update',
+ click: () => this.showConfirmationModal($localize`out`, this.osdService.markOut),
+ disable: () => this.isNotSelectedOrInState('out'),
+ icon: Icons.left
+ },
+ {
+ name: this.actionLabels.MARK_IN,
+ permission: 'update',
+ click: () => this.showConfirmationModal($localize`in`, this.osdService.markIn),
+ disable: () => this.isNotSelectedOrInState('in'),
+ icon: Icons.right
+ },
+ {
+ name: this.actionLabels.MARK_DOWN,
+ permission: 'update',
+ click: () => this.showConfirmationModal($localize`down`, this.osdService.markDown),
+ disable: () => this.isNotSelectedOrInState('down'),
+ icon: Icons.down
+ },
+ {
+ name: this.actionLabels.MARK_LOST,
+ permission: 'delete',
+ click: () =>
+ this.showCriticalConfirmationModal(
+ $localize`Mark`,
+ $localize`OSD lost`,
+ $localize`marked lost`,
+ (ids: number[]) => {
+ return this.osdService.safeToDestroy(JSON.stringify(ids));
+ },
+ 'is_safe_to_destroy',
+ this.osdService.markLost
+ ),
+ disable: () => this.isNotSelectedOrInState('up'),
+ icon: Icons.flatten
+ },
+ {
+ name: this.actionLabels.PURGE,
+ permission: 'delete',
+ click: () =>
+ this.showCriticalConfirmationModal(
+ $localize`Purge`,
+ $localize`OSD`,
+ $localize`purged`,
+ (ids: number[]) => {
+ return this.osdService.safeToDestroy(JSON.stringify(ids));
+ },
+ 'is_safe_to_destroy',
+ (id: number) => {
+ this.selection = new CdTableSelection();
+ return this.osdService.purge(id);
+ }
+ ),
+ disable: () => this.isNotSelectedOrInState('up'),
+ icon: Icons.erase
+ },
+ {
+ name: this.actionLabels.DESTROY,
+ permission: 'delete',
+ click: () =>
+ this.showCriticalConfirmationModal(
+ $localize`destroy`,
+ $localize`OSD`,
+ $localize`destroyed`,
+ (ids: number[]) => {
+ return this.osdService.safeToDestroy(JSON.stringify(ids));
+ },
+ 'is_safe_to_destroy',
+ (id: number) => {
+ this.selection = new CdTableSelection();
+ return this.osdService.destroy(id);
+ }
+ ),
+ disable: () => this.isNotSelectedOrInState('up'),
+ icon: Icons.destroyCircle
+ },
+ {
+ name: this.actionLabels.DELETE,
+ permission: 'delete',
+ click: () => this.delete(),
+ disable: (selection: CdTableSelection) => this.getDisable('delete', selection),
+ icon: Icons.destroy
+ }
+ ];
+ }
+
+ ngOnInit() {
+ this.clusterWideActions = [
+ {
+ name: $localize`Flags`,
+ icon: Icons.flag,
+ click: () => this.configureFlagsAction(),
+ permission: 'read',
+ visible: () => this.permissions.osd.read
+ },
+ {
+ name: $localize`Recovery Priority`,
+ icon: Icons.deepCheck,
+ click: () => this.configureQosParamsAction(),
+ permission: 'read',
+ visible: () => this.permissions.configOpt.read
+ },
+ {
+ name: $localize`PG scrub`,
+ icon: Icons.analyse,
+ click: () => this.configurePgScrubAction(),
+ permission: 'read',
+ visible: () => this.permissions.configOpt.read
+ }
+ ];
+ this.columns = [
+ {
+ prop: 'id',
+ name: $localize`ID`,
+ flexGrow: 1,
+ cellTransformation: CellTemplate.executing,
+ customTemplateConfig: {
+ valueClass: 'bold'
+ }
+ },
+ { prop: 'host.name', name: $localize`Host` },
+ {
+ prop: 'collectedStates',
+ name: $localize`Status`,
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ in: { class: 'badge-success' },
+ up: { class: 'badge-success' },
+ down: { class: 'badge-danger' },
+ out: { class: 'badge-danger' },
+ destroyed: { class: 'badge-danger' }
+ }
+ }
+ },
+ {
+ prop: 'tree.device_class',
+ name: $localize`Device class`,
+ flexGrow: 1.2,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ hdd: { class: 'badge-hdd' },
+ ssd: { class: 'badge-ssd' }
+ }
+ }
+ },
+ {
+ prop: 'stats.numpg',
+ name: $localize`PGs`,
+ flexGrow: 1
+ },
+ {
+ prop: 'stats.stat_bytes',
+ name: $localize`Size`,
+ flexGrow: 1,
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ prop: 'state',
+ name: $localize`Flags`,
+ cellTemplate: this.flagsTpl
+ },
+ { prop: 'stats.usage', name: $localize`Usage`, cellTemplate: this.osdUsageTpl },
+ {
+ prop: 'stats_history.out_bytes',
+ name: $localize`Read bytes`,
+ cellTransformation: CellTemplate.sparkline
+ },
+ {
+ prop: 'stats_history.in_bytes',
+ name: $localize`Write bytes`,
+ cellTransformation: CellTemplate.sparkline
+ },
+ {
+ prop: 'stats.op_r',
+ name: $localize`Read ops`,
+ cellTransformation: CellTemplate.perSecond
+ },
+ {
+ prop: 'stats.op_w',
+ name: $localize`Write ops`,
+ cellTransformation: CellTemplate.perSecond
+ }
+ ];
+
+ this.orchService.status().subscribe((status: OrchestratorStatus) => (this.orchStatus = status));
+
+ this.osdService
+ .getOsdSettings()
+ .pipe(take(1))
+ .subscribe((data: any) => {
+ this.osdSettings = data;
+ });
+ }
+
+ getDisable(action: 'create' | 'delete', selection: CdTableSelection): boolean | string {
+ if (action === 'delete') {
+ if (!selection.hasSelection) {
+ return true;
+ } else {
+ // Disable delete action if any selected OSDs are under deleting or unmanaged.
+ const deletingOSDs = _.some(this.getSelectedOsds(), (osd) => {
+ const status = _.get(osd, 'operational_status');
+ return status === 'deleting' || status === 'unmanaged';
+ });
+ if (deletingOSDs) {
+ return true;
+ }
+ }
+ }
+ return this.orchService.getTableActionDisableDesc(
+ this.orchStatus,
+ this.actionOrchFeatures[action]
+ );
+ }
+
+ /**
+ * Only returns valid IDs, e.g. if an OSD is falsely still selected after being deleted, it won't
+ * get returned.
+ */
+ getSelectedOsdIds(): number[] {
+ const osdIds = this.osds.map((osd) => osd.id);
+ return this.selection.selected
+ .map((row) => row.id)
+ .filter((id) => osdIds.includes(id))
+ .sort();
+ }
+
+ getSelectedOsds(): any[] {
+ return this.osds.filter(
+ (osd) => !_.isUndefined(osd) && this.getSelectedOsdIds().includes(osd.id)
+ );
+ }
+
+ get hasOsdSelected(): boolean {
+ return this.getSelectedOsdIds().length > 0;
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ /**
+ * Returns true if no rows are selected or if *any* of the selected rows are in the given
+ * state. Useful for deactivating the corresponding menu entry.
+ */
+ isNotSelectedOrInState(state: 'in' | 'up' | 'down' | 'out'): boolean {
+ const selectedOsds = this.getSelectedOsds();
+ if (selectedOsds.length === 0) {
+ return true;
+ }
+ switch (state) {
+ case 'in':
+ return selectedOsds.some((osd) => osd.in === 1);
+ case 'out':
+ return selectedOsds.some((osd) => osd.in !== 1);
+ case 'down':
+ return selectedOsds.some((osd) => osd.up !== 1);
+ case 'up':
+ return selectedOsds.some((osd) => osd.up === 1);
+ }
+ }
+
+ getOsdList() {
+ const observables = [this.osdService.getList(), this.osdService.getFlags()];
+ observableForkJoin(observables).subscribe((resp: [any[], string[]]) => {
+ this.osds = resp[0].map((osd) => {
+ osd.collectedStates = OsdListComponent.collectStates(osd);
+ osd.stats_history.out_bytes = osd.stats_history.op_out_bytes.map((i: string) => i[1]);
+ osd.stats_history.in_bytes = osd.stats_history.op_in_bytes.map((i: string) => i[1]);
+ osd.stats.usage = osd.stats.stat_bytes_used / osd.stats.stat_bytes;
+ osd.cdIsBinary = true;
+ osd.cdIndivFlags = osd.state.filter((f: string) => this.indivFlagNames.includes(f));
+ osd.cdClusterFlags = resp[1].filter((f: string) => !this.disabledFlags.includes(f));
+ const deploy_state = _.get(osd, 'operational_status', 'unmanaged');
+ if (deploy_state !== 'unmanaged' && deploy_state !== 'working') {
+ osd.cdExecuting = deploy_state;
+ }
+ return osd;
+ });
+ });
+ }
+
+ editAction() {
+ const selectedOsd = _.filter(this.osds, ['id', this.selection.first().id]).pop();
+
+ this.modalService.show(FormModalComponent, {
+ titleText: $localize`Edit OSD: ${selectedOsd.id}`,
+ fields: [
+ {
+ type: 'text',
+ name: 'deviceClass',
+ value: selectedOsd.tree.device_class,
+ label: $localize`Device class`,
+ required: true
+ }
+ ],
+ submitButtonText: $localize`Edit OSD`,
+ onSubmit: (values: any) => {
+ this.osdService.update(selectedOsd.id, values.deviceClass).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated OSD '${selectedOsd.id}'`
+ );
+ this.getOsdList();
+ });
+ }
+ });
+ }
+
+ scrubAction(deep: boolean) {
+ if (!this.hasOsdSelected) {
+ return;
+ }
+
+ const initialState = {
+ selected: this.getSelectedOsdIds(),
+ deep: deep
+ };
+
+ this.bsModalRef = this.modalService.show(OsdScrubModalComponent, initialState);
+ }
+
+ configureFlagsAction() {
+ this.bsModalRef = this.modalService.show(OsdFlagsModalComponent);
+ }
+
+ configureFlagsIndivAction() {
+ const initialState = {
+ selected: this.getSelectedOsds()
+ };
+ this.bsModalRef = this.modalService.show(OsdFlagsIndivModalComponent, initialState);
+ }
+
+ showConfirmationModal(markAction: string, onSubmit: (id: number) => Observable<any>) {
+ const osdIds = this.getSelectedOsdIds();
+ this.bsModalRef = this.modalService.show(ConfirmationModalComponent, {
+ titleText: $localize`Mark OSD ${markAction}`,
+ buttonText: $localize`Mark ${markAction}`,
+ bodyTpl: this.markOsdConfirmationTpl,
+ bodyContext: {
+ markActionDescription: markAction,
+ osdIds
+ },
+ onSubmit: () => {
+ observableForkJoin(
+ this.getSelectedOsdIds().map((osd: any) => onSubmit.call(this.osdService, osd))
+ ).subscribe(() => this.bsModalRef.close());
+ }
+ });
+ }
+
+ reweight() {
+ const selectedOsd = this.osds.filter((o) => o.id === this.selection.first().id).pop();
+ this.bsModalRef = this.modalService.show(OsdReweightModalComponent, {
+ currentWeight: selectedOsd.weight,
+ osdId: selectedOsd.id
+ });
+ }
+
+ delete() {
+ const deleteFormGroup = new CdFormGroup({
+ preserve: new FormControl(false)
+ });
+
+ this.showCriticalConfirmationModal(
+ $localize`delete`,
+ $localize`OSD`,
+ $localize`deleted`,
+ (ids: number[]) => {
+ return this.osdService.safeToDelete(JSON.stringify(ids));
+ },
+ 'is_safe_to_delete',
+ (id: number) => {
+ this.selection = new CdTableSelection();
+ return this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('osd/' + URLVerbs.DELETE, {
+ svc_id: id
+ }),
+ call: this.osdService.delete(id, deleteFormGroup.value.preserve, true)
+ });
+ },
+ true,
+ deleteFormGroup,
+ this.deleteOsdExtraTpl
+ );
+ }
+
+ /**
+ * Perform check first and display a critical confirmation modal.
+ * @param {string} actionDescription name of the action.
+ * @param {string} itemDescription the item's name that the action operates on.
+ * @param {string} templateItemDescription the action name to be displayed in modal template.
+ * @param {Function} check the function is called to check if the action is safe.
+ * @param {string} checkKey the safe indicator's key in the check response.
+ * @param {Function} action the action function.
+ * @param {boolean} taskWrapped if true, hide confirmation modal after action
+ * @param {CdFormGroup} childFormGroup additional child form group to be passed to confirmation modal
+ * @param {TemplateRef<any>} childFormGroupTemplate template for additional child form group
+ */
+ showCriticalConfirmationModal(
+ actionDescription: string,
+ itemDescription: string,
+ templateItemDescription: string,
+ check: (ids: number[]) => Observable<any>,
+ checkKey: string,
+ action: (id: number | number[]) => Observable<any>,
+ taskWrapped: boolean = false,
+ childFormGroup?: CdFormGroup,
+ childFormGroupTemplate?: TemplateRef<any>
+ ): void {
+ check(this.getSelectedOsdIds()).subscribe((result) => {
+ const modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ actionDescription: actionDescription,
+ itemDescription: itemDescription,
+ bodyTemplate: this.criticalConfirmationTpl,
+ bodyContext: {
+ safeToPerform: result[checkKey],
+ message: result.message,
+ active: result.active,
+ missingStats: result.missing_stats,
+ storedPgs: result.stored_pgs,
+ actionDescription: templateItemDescription,
+ osdIds: this.getSelectedOsdIds()
+ },
+ childFormGroup: childFormGroup,
+ childFormGroupTemplate: childFormGroupTemplate,
+ submitAction: () => {
+ const observable = observableForkJoin(
+ this.getSelectedOsdIds().map((osd: any) => action.call(this.osdService, osd))
+ );
+ if (taskWrapped) {
+ observable.subscribe({
+ error: () => {
+ this.getOsdList();
+ modalRef.close();
+ },
+ complete: () => modalRef.close()
+ });
+ } else {
+ observable.subscribe(
+ () => {
+ this.getOsdList();
+ modalRef.close();
+ },
+ () => modalRef.close()
+ );
+ }
+ }
+ });
+ });
+ }
+
+ configureQosParamsAction() {
+ this.bsModalRef = this.modalService.show(OsdRecvSpeedModalComponent);
+ }
+
+ configurePgScrubAction() {
+ this.bsModalRef = this.modalService.show(OsdPgScrubModalComponent, undefined, { size: 'lg' });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.html
new file mode 100644
index 000000000..fa2636722
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.html
@@ -0,0 +1,45 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form #formDir="ngForm"
+ [formGroup]="osdPgScrubForm"
+ novalidate
+ cdFormScope="osd">
+ <div class="modal-body osd-modal">
+ <!-- Basic -->
+ <cd-config-option [optionNames]="basicOptions"
+ [optionsForm]="osdPgScrubForm"
+ [optionsFormDir]="formDir"
+ [optionsFormGroupName]="'basicFormGroup'"
+ #basicOptionsValues></cd-config-option>
+ <!-- Advanced -->
+ <div class="row">
+ <div class="col-sm-12">
+ <a class="pull-right margin-right-md"
+ (click)="advancedEnabled = true"
+ *ngIf="!advancedEnabled"
+ i18n>Advanced...</a>
+ </div>
+ </div>
+ <div *ngIf="advancedEnabled">
+ <h3 class="page-header"
+ i18n>Advanced configuration options</h3>
+ <cd-config-option [optionNames]="advancedOptions"
+ [optionsForm]="osdPgScrubForm"
+ [optionsFormDir]="formDir"
+ [optionsFormGroupName]="'advancedFormGroup'"
+ #advancedOptionsValues></cd-config-option>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submitAction()"
+ [form]="osdPgScrubForm"
+ [showSubmit]="permissions.configOpt.update"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)">
+ </cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.spec.ts
new file mode 100644
index 000000000..dc5fc1644
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.spec.ts
@@ -0,0 +1,64 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of as observableOf } from 'rxjs';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdPgScrubModalComponent } from './osd-pg-scrub-modal.component';
+
+describe('OsdPgScrubModalComponent', () => {
+ let component: OsdPgScrubModalComponent;
+ let fixture: ComponentFixture<OsdPgScrubModalComponent>;
+ let configurationService: ConfigurationService;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [OsdPgScrubModalComponent],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdPgScrubModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ configurationService = TestBed.inject(ConfigurationService);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('submitAction', () => {
+ let notificationService: NotificationService;
+
+ beforeEach(() => {
+ spyOn(TestBed.inject(Router), 'navigate').and.stub();
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show');
+ });
+
+ it('test create success notification', () => {
+ spyOn(configurationService, 'bulkCreate').and.returnValue(observableOf([]));
+ component.submitAction();
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ 'Updated PG scrub options'
+ );
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.ts
new file mode 100644
index 000000000..7e76c99c5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component.ts
@@ -0,0 +1,68 @@
+import { Component, ViewChild } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { forkJoin as observableForkJoin } from 'rxjs';
+
+import { ConfigOptionComponent } from '~/app/shared/components/config-option/config-option.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { OsdPgScrubModalOptions } from './osd-pg-scrub-modal.options';
+
+@Component({
+ selector: 'cd-osd-pg-scrub-modal',
+ templateUrl: './osd-pg-scrub-modal.component.html',
+ styleUrls: ['./osd-pg-scrub-modal.component.scss']
+})
+export class OsdPgScrubModalComponent {
+ osdPgScrubForm: CdFormGroup;
+ action: string;
+ resource: string;
+ permissions: Permissions;
+
+ @ViewChild('basicOptionsValues', { static: true })
+ basicOptionsValues: ConfigOptionComponent;
+ basicOptions: Array<string> = OsdPgScrubModalOptions.basicOptions;
+
+ @ViewChild('advancedOptionsValues')
+ advancedOptionsValues: ConfigOptionComponent;
+ advancedOptions: Array<string> = OsdPgScrubModalOptions.advancedOptions;
+
+ advancedEnabled = false;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private authStorageService: AuthStorageService,
+ private notificationService: NotificationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.osdPgScrubForm = new CdFormGroup({});
+ this.resource = $localize`PG scrub options`;
+ this.action = this.actionLabels.EDIT;
+ this.permissions = this.authStorageService.getPermissions();
+ }
+
+ submitAction() {
+ const observables = [this.basicOptionsValues.saveValues()];
+
+ if (this.advancedOptionsValues) {
+ observables.push(this.advancedOptionsValues.saveValues());
+ }
+
+ observableForkJoin(observables).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated PG scrub options`
+ );
+ this.activeModal.close();
+ },
+ () => {
+ this.activeModal.close();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.options.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.options.ts
new file mode 100644
index 000000000..424fbc479
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-pg-scrub-modal/osd-pg-scrub-modal.options.ts
@@ -0,0 +1,36 @@
+export class OsdPgScrubModalOptions {
+ public static basicOptions: Array<string> = [
+ 'osd_scrub_during_recovery',
+ 'osd_scrub_begin_hour',
+ 'osd_scrub_end_hour',
+ 'osd_scrub_begin_week_day',
+ 'osd_scrub_end_week_day',
+ 'osd_scrub_min_interval',
+ 'osd_scrub_max_interval',
+ 'osd_deep_scrub_interval',
+ 'osd_scrub_auto_repair',
+ 'osd_max_scrubs',
+ 'osd_scrub_priority',
+ 'osd_scrub_sleep'
+ ];
+
+ public static advancedOptions: Array<string> = [
+ 'osd_scrub_auto_repair_num_errors',
+ 'osd_debug_deep_scrub_sleep',
+ 'osd_deep_scrub_keys',
+ 'osd_deep_scrub_large_omap_object_key_threshold',
+ 'osd_deep_scrub_large_omap_object_value_sum_threshold',
+ 'osd_deep_scrub_randomize_ratio',
+ 'osd_deep_scrub_stride',
+ 'osd_deep_scrub_update_digest_min_age',
+ 'osd_requested_scrub_priority',
+ 'osd_scrub_backoff_ratio',
+ 'osd_scrub_chunk_max',
+ 'osd_scrub_chunk_min',
+ 'osd_scrub_cost',
+ 'osd_scrub_interval_randomize_ratio',
+ 'osd_scrub_invalid_stats',
+ 'osd_scrub_load_threshold',
+ 'osd_scrub_max_preemptions'
+ ];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html
new file mode 100755
index 000000000..eb54c82f5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html
@@ -0,0 +1,92 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>OSD Recovery Priority</ng-container>
+
+ <ng-container class="modal-content">
+ <form #formDir="ngForm"
+ [formGroup]="osdRecvSpeedForm"
+ novalidate
+ cdFormScope="osd">
+ <div class="modal-body">
+ <!-- Priority -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="priority"
+ i18n>Priority</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="priority"
+ id="priority"
+ (change)="onPriorityChange($event.target.value)">
+ <option *ngFor="let priority of priorities"
+ [value]="priority.name">
+ {{ priority.text }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="osdRecvSpeedForm.showError('priority', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Customize priority -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input formControlName="customizePriority"
+ class="custom-control-input"
+ id="customizePriority"
+ name="customizePriority"
+ type="checkbox"
+ (change)="onCustomizePriorityChange()">
+ <label class="custom-control-label"
+ for="customizePriority"
+ i18n>Customize priority values</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Priority values -->
+ <div class="form-group row"
+ *ngFor="let attr of priorityAttrs | keyvalue">
+ <label class="cd-col-form-label"
+ [for]="attr.key">
+ <span [ngClass]="{'required': osdRecvSpeedForm.getValue('customizePriority')}">
+ {{ attr.value.text }}
+ </span>
+ <cd-helper *ngIf="attr.value.desc">{{ attr.value.desc }}</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="number"
+ [id]="attr.key"
+ [formControlName]="attr.key"
+ [readonly]="!osdRecvSpeedForm.getValue('customizePriority')">
+ <span class="invalid-feedback"
+ *ngIf="osdRecvSpeedForm.getValue('customizePriority') &&
+ osdRecvSpeedForm.showError(attr.key, formDir, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="osdRecvSpeedForm.getValue('customizePriority') &&
+ osdRecvSpeedForm.showError(attr.key, formDir, 'pattern')"
+ i18n>{{ attr.value.patternHelpText }}</span>
+ <span class="invalid-feedback"
+ *ngIf="osdRecvSpeedForm.getValue('customizePriority') &&
+ osdRecvSpeedForm.showError(attr.key, formDir, 'max')"
+ i18n>The entered value is too high! It must not be greater than {{ attr.value.maxValue }}.</span>
+ <span class="invalid-feedback"
+ *ngIf="osdRecvSpeedForm.getValue('customizePriority') &&
+ osdRecvSpeedForm.showError(attr.key, formDir, 'min')"
+ i18n>The entered value is too low! It must not be lower than {{ attr.value.minValue }}.</span>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submitAction()"
+ [form]="osdRecvSpeedForm"
+ [submitText]="actionLabels.UPDATE"
+ [showSubmit]="permissions.configOpt.update"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.scss
new file mode 100755
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.spec.ts
new file mode 100755
index 000000000..f8b72940b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.spec.ts
@@ -0,0 +1,317 @@
+import { HttpClientTestingModule } 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 } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { of as observableOf } from 'rxjs';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdRecvSpeedModalComponent } from './osd-recv-speed-modal.component';
+
+describe('OsdRecvSpeedModalComponent', () => {
+ let component: OsdRecvSpeedModalComponent;
+ let fixture: ComponentFixture<OsdRecvSpeedModalComponent>;
+ let configurationService: ConfigurationService;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [OsdRecvSpeedModalComponent],
+ providers: [NgbActiveModal]
+ });
+
+ let configOptions: any[] = [];
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdRecvSpeedModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ configurationService = TestBed.inject(ConfigurationService);
+ configOptions = [
+ {
+ name: 'osd_max_backfills',
+ desc: '',
+ type: 'uint',
+ default: 1
+ },
+ {
+ name: 'osd_recovery_max_active',
+ desc: '',
+ type: 'uint',
+ default: 3
+ },
+ {
+ name: 'osd_recovery_max_single_start',
+ desc: '',
+ type: 'uint',
+ default: 1
+ },
+ {
+ name: 'osd_recovery_sleep',
+ desc: 'Time in seconds to sleep before next recovery or backfill op',
+ type: 'float',
+ default: 0
+ }
+ ];
+ spyOn(configurationService, 'filter').and.returnValue(observableOf(configOptions));
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('ngOnInit', () => {
+ let setPriority: jasmine.Spy;
+ let setValidators: jasmine.Spy;
+
+ beforeEach(() => {
+ setPriority = spyOn(component, 'setPriority').and.callThrough();
+ setValidators = spyOn(component, 'setValidators').and.callThrough();
+ component.ngOnInit();
+ });
+
+ it('should call setValidators', () => {
+ expect(setValidators).toHaveBeenCalled();
+ });
+
+ it('should get and set priority correctly', () => {
+ const defaultPriority = _.find(component.priorities, (p) => {
+ return _.isEqual(p.name, 'default');
+ });
+ expect(setPriority).toHaveBeenCalledWith(defaultPriority);
+ });
+
+ it('should set descriptions correctly', () => {
+ expect(component.priorityAttrs['osd_max_backfills'].desc).toBe('');
+ expect(component.priorityAttrs['osd_recovery_max_active'].desc).toBe('');
+ expect(component.priorityAttrs['osd_recovery_max_single_start'].desc).toBe('');
+ expect(component.priorityAttrs['osd_recovery_sleep'].desc).toBe(
+ 'Time in seconds to sleep before next recovery or backfill op'
+ );
+ });
+ });
+
+ describe('setPriority', () => {
+ it('should prepare the form for a custom priority', () => {
+ const customPriority = {
+ name: 'custom',
+ text: 'Custom',
+ values: {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 4,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 1
+ }
+ };
+
+ component.setPriority(customPriority);
+
+ const customInPriorities = _.find(component.priorities, (p) => {
+ return p.name === 'custom';
+ });
+
+ expect(customInPriorities).not.toBeNull();
+ expect(component.osdRecvSpeedForm.getValue('priority')).toBe('custom');
+ expect(component.osdRecvSpeedForm.getValue('osd_max_backfills')).toBe(1);
+ expect(component.osdRecvSpeedForm.getValue('osd_recovery_max_active')).toBe(4);
+ expect(component.osdRecvSpeedForm.getValue('osd_recovery_max_single_start')).toBe(1);
+ expect(component.osdRecvSpeedForm.getValue('osd_recovery_sleep')).toBe(1);
+ });
+
+ it('should prepare the form for a none custom priority', () => {
+ const lowPriority = {
+ name: 'low',
+ text: 'Low',
+ values: {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 1,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0.5
+ }
+ };
+
+ component.setPriority(lowPriority);
+
+ const customInPriorities = _.find(component.priorities, (p) => {
+ return p.name === 'custom';
+ });
+
+ expect(customInPriorities).toBeUndefined();
+ expect(component.osdRecvSpeedForm.getValue('priority')).toBe('low');
+ expect(component.osdRecvSpeedForm.getValue('osd_max_backfills')).toBe(1);
+ expect(component.osdRecvSpeedForm.getValue('osd_recovery_max_active')).toBe(1);
+ expect(component.osdRecvSpeedForm.getValue('osd_recovery_max_single_start')).toBe(1);
+ expect(component.osdRecvSpeedForm.getValue('osd_recovery_sleep')).toBe(0.5);
+ });
+ });
+
+ describe('detectPriority', () => {
+ const configOptionsLow = {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 1,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0.5
+ };
+
+ const configOptionsDefault = {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 3,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0
+ };
+
+ const configOptionsHigh = {
+ osd_max_backfills: 4,
+ osd_recovery_max_active: 4,
+ osd_recovery_max_single_start: 4,
+ osd_recovery_sleep: 0
+ };
+
+ const configOptionsCustom = {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 2,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0
+ };
+
+ const configOptionsIncomplete = {
+ osd_max_backfills: 1,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0
+ };
+
+ it('should return priority "low" if the config option values have been set accordingly', () => {
+ component.detectPriority(configOptionsLow, (priority: Record<string, any>) => {
+ expect(priority.name).toBe('low');
+ });
+ expect(component.osdRecvSpeedForm.getValue('customizePriority')).toBeFalsy();
+ });
+
+ it('should return priority "default" if the config option values have been set accordingly', () => {
+ component.detectPriority(configOptionsDefault, (priority: Record<string, any>) => {
+ expect(priority.name).toBe('default');
+ });
+ expect(component.osdRecvSpeedForm.getValue('customizePriority')).toBeFalsy();
+ });
+
+ it('should return priority "high" if the config option values have been set accordingly', () => {
+ component.detectPriority(configOptionsHigh, (priority: Record<string, any>) => {
+ expect(priority.name).toBe('high');
+ });
+ expect(component.osdRecvSpeedForm.getValue('customizePriority')).toBeFalsy();
+ });
+
+ it('should return priority "custom" if the config option values do not match any priority', () => {
+ component.detectPriority(configOptionsCustom, (priority: Record<string, any>) => {
+ expect(priority.name).toBe('custom');
+ });
+ expect(component.osdRecvSpeedForm.getValue('customizePriority')).toBeTruthy();
+ });
+
+ it('should return no priority if the config option values are incomplete', () => {
+ component.detectPriority(configOptionsIncomplete, (priority: Record<string, any>) => {
+ expect(priority.name).toBeNull();
+ });
+ expect(component.osdRecvSpeedForm.getValue('customizePriority')).toBeFalsy();
+ });
+ });
+
+ describe('getCurrentValues', () => {
+ it('should return default values if no value has been set by the user', () => {
+ const currentValues = component.getCurrentValues(configOptions);
+ configOptions.forEach((configOption) => {
+ const configOptionValue = currentValues.values[configOption.name];
+ expect(configOptionValue).toBe(configOption.default);
+ });
+ });
+
+ it('should return the values set by the user if they exist', () => {
+ configOptions.forEach((configOption) => {
+ configOption['value'] = [{ section: 'osd', value: 7 }];
+ });
+
+ const currentValues = component.getCurrentValues(configOptions);
+ Object.values(currentValues.values).forEach((configValue) => {
+ expect(configValue).toBe(7);
+ });
+ });
+
+ it('should return the default value if one is missing', () => {
+ for (let i = 1; i < configOptions.length; i++) {
+ configOptions[i]['value'] = [{ section: 'osd', value: 7 }];
+ }
+
+ const currentValues = component.getCurrentValues(configOptions);
+ Object.entries(currentValues.values).forEach(([configName, configValue]) => {
+ if (configName === 'osd_max_backfills') {
+ expect(configValue).toBe(1);
+ } else {
+ expect(configValue).toBe(7);
+ }
+ });
+ });
+
+ it('should return nothing if neither value nor default value is given', () => {
+ configOptions[0].default = null;
+ const currentValues = component.getCurrentValues(configOptions);
+ expect(currentValues.values).not.toContain('osd_max_backfills');
+ });
+ });
+
+ describe('setDescription', () => {
+ it('should set the description if one is given', () => {
+ component.setDescription(configOptions);
+ Object.keys(component.priorityAttrs).forEach((configOptionName) => {
+ if (configOptionName === 'osd_recovery_sleep') {
+ expect(component.priorityAttrs[configOptionName].desc).toBe(
+ 'Time in seconds to sleep before next recovery or backfill op'
+ );
+ } else {
+ expect(component.priorityAttrs[configOptionName].desc).toBe('');
+ }
+ });
+ });
+ });
+
+ describe('setValidators', () => {
+ it('should set needed validators for config option', () => {
+ component.setValidators(configOptions);
+ configOptions.forEach((configOption) => {
+ const control = component.osdRecvSpeedForm.controls[configOption.name];
+
+ if (configOption.type === 'float') {
+ expect(component.priorityAttrs[configOption.name].patternHelpText).toBe(
+ 'The entered value needs to be a number or decimal.'
+ );
+ } else {
+ expect(component.priorityAttrs[configOption.name].minValue).toBe(0);
+ expect(component.priorityAttrs[configOption.name].patternHelpText).toBe(
+ 'The entered value needs to be an unsigned number.'
+ );
+
+ control.setValue(-1);
+ expect(control.hasError('min')).toBeTruthy();
+ }
+
+ control.setValue(null);
+ expect(control.hasError('required')).toBeTruthy();
+ control.setValue('E');
+ expect(control.hasError('pattern')).toBeTruthy();
+ control.setValue(3);
+ expect(control.hasError('required')).toBeFalsy();
+ expect(control.hasError('min')).toBeFalsy();
+ expect(control.hasError('pattern')).toBeFalsy();
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.ts
new file mode 100755
index 000000000..6546e0865
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.ts
@@ -0,0 +1,238 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ConfigOptionTypes } from '~/app/shared/components/config-option/config-option.types';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-osd-recv-speed-modal',
+ templateUrl: './osd-recv-speed-modal.component.html',
+ styleUrls: ['./osd-recv-speed-modal.component.scss']
+})
+export class OsdRecvSpeedModalComponent implements OnInit {
+ osdRecvSpeedForm: CdFormGroup;
+ permissions: Permissions;
+
+ priorities: any[] = [];
+ priorityAttrs = {};
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private configService: ConfigurationService,
+ private notificationService: NotificationService,
+ private osdService: OsdService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ this.priorities = this.osdService.osdRecvSpeedModalPriorities.KNOWN_PRIORITIES;
+ this.osdRecvSpeedForm = new CdFormGroup({
+ priority: new FormControl(null, { validators: [Validators.required] }),
+ customizePriority: new FormControl(false)
+ });
+ this.priorityAttrs = {
+ osd_max_backfills: {
+ text: $localize`Max Backfills`,
+ desc: '',
+ patternHelpText: '',
+ maxValue: undefined,
+ minValue: undefined
+ },
+ osd_recovery_max_active: {
+ text: $localize`Recovery Max Active`,
+ desc: '',
+ patternHelpText: '',
+ maxValue: undefined,
+ minValue: undefined
+ },
+ osd_recovery_max_single_start: {
+ text: $localize`Recovery Max Single Start`,
+ desc: '',
+ patternHelpText: '',
+ maxValue: undefined,
+ minValue: undefined
+ },
+ osd_recovery_sleep: {
+ text: $localize`Recovery Sleep`,
+ desc: '',
+ patternHelpText: '',
+ maxValue: undefined,
+ minValue: undefined
+ }
+ };
+
+ Object.keys(this.priorityAttrs).forEach((configOptionName) => {
+ this.osdRecvSpeedForm.addControl(
+ configOptionName,
+ new FormControl(null, { validators: [Validators.required] })
+ );
+ });
+ }
+
+ ngOnInit() {
+ this.configService.filter(Object.keys(this.priorityAttrs)).subscribe((data: any) => {
+ const config_option_values = this.getCurrentValues(data);
+ this.detectPriority(config_option_values.values, (priority: any) => {
+ this.setPriority(priority);
+ });
+ this.setDescription(config_option_values.configOptions);
+ this.setValidators(config_option_values.configOptions);
+ });
+ }
+
+ detectPriority(configOptionValues: any, callbackFn: Function) {
+ const priority = _.find(this.priorities, (p) => {
+ return _.isEqual(p.values, configOptionValues);
+ });
+
+ this.osdRecvSpeedForm.controls.customizePriority.setValue(false);
+
+ if (priority) {
+ return callbackFn(priority);
+ }
+
+ if (Object.entries(configOptionValues).length === 4) {
+ this.osdRecvSpeedForm.controls.customizePriority.setValue(true);
+ return callbackFn(
+ Object({ name: 'custom', text: $localize`Custom`, values: configOptionValues })
+ );
+ }
+
+ return callbackFn(this.priorities[0]);
+ }
+
+ getCurrentValues(configOptions: any) {
+ const currentValues: Record<string, any> = { values: {}, configOptions: [] };
+ configOptions.forEach((configOption: any) => {
+ currentValues.configOptions.push(configOption);
+
+ if ('value' in configOption) {
+ configOption.value.forEach((value: any) => {
+ if (value.section === 'osd') {
+ currentValues.values[configOption.name] = Number(value.value);
+ }
+ });
+ } else if ('default' in configOption && configOption.default !== null) {
+ currentValues.values[configOption.name] = Number(configOption.default);
+ }
+ });
+ return currentValues;
+ }
+
+ setDescription(configOptions: Array<any>) {
+ configOptions.forEach((configOption) => {
+ if (configOption.desc !== '') {
+ this.priorityAttrs[configOption.name].desc = configOption.desc;
+ }
+ });
+ }
+
+ setPriority(priority: any) {
+ const customPriority = _.find(this.priorities, (p) => {
+ return p.name === 'custom';
+ });
+
+ if (priority.name === 'custom') {
+ if (!customPriority) {
+ this.priorities.push(priority);
+ }
+ } else {
+ if (customPriority) {
+ this.priorities.splice(this.priorities.indexOf(customPriority), 1);
+ }
+ }
+
+ this.osdRecvSpeedForm.controls.priority.setValue(priority.name);
+ Object.entries(priority.values).forEach(([name, value]) => {
+ this.osdRecvSpeedForm.controls[name].setValue(value);
+ });
+ }
+
+ setValidators(configOptions: Array<any>) {
+ configOptions.forEach((configOption) => {
+ const typeValidators = ConfigOptionTypes.getTypeValidators(configOption);
+ if (typeValidators) {
+ typeValidators.validators.push(Validators.required);
+
+ if ('max' in typeValidators && typeValidators.max !== '') {
+ this.priorityAttrs[configOption.name].maxValue = typeValidators.max;
+ }
+
+ if ('min' in typeValidators && typeValidators.min !== '') {
+ this.priorityAttrs[configOption.name].minValue = typeValidators.min;
+ }
+
+ this.priorityAttrs[configOption.name].patternHelpText = typeValidators.patternHelpText;
+ this.osdRecvSpeedForm.controls[configOption.name].setValidators(typeValidators.validators);
+ } else {
+ this.osdRecvSpeedForm.controls[configOption.name].setValidators(Validators.required);
+ }
+ });
+ }
+
+ onCustomizePriorityChange() {
+ const values = {};
+ Object.keys(this.priorityAttrs).forEach((configOptionName) => {
+ values[configOptionName] = this.osdRecvSpeedForm.getValue(configOptionName);
+ });
+
+ if (this.osdRecvSpeedForm.getValue('customizePriority')) {
+ const customPriority = {
+ name: 'custom',
+ text: $localize`Custom`,
+ values: values
+ };
+ this.setPriority(customPriority);
+ } else {
+ this.detectPriority(values, (priority: any) => {
+ this.setPriority(priority);
+ });
+ }
+ }
+
+ onPriorityChange(selectedPriorityName: string) {
+ const selectedPriority =
+ _.find(this.priorities, (p) => {
+ return p.name === selectedPriorityName;
+ }) || this.priorities[0];
+ // Uncheck the 'Customize priority values' checkbox.
+ this.osdRecvSpeedForm.get('customizePriority').setValue(false);
+ // Set the priority profile values.
+ this.setPriority(selectedPriority);
+ }
+
+ submitAction() {
+ const options = {};
+ Object.keys(this.priorityAttrs).forEach((configOptionName) => {
+ options[configOptionName] = {
+ section: 'osd',
+ value: this.osdRecvSpeedForm.getValue(configOptionName)
+ };
+ });
+
+ this.configService.bulkCreate({ options: options }).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated OSD recovery speed priority '${this.osdRecvSpeedForm.getValue(
+ 'priority'
+ )}'`
+ );
+ this.activeModal.close();
+ },
+ () => {
+ this.activeModal.close();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html
new file mode 100644
index 000000000..e5aa22311
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html
@@ -0,0 +1,38 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container class="modal-title"
+ i18n>Reweight OSD: {{ osdId }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form [formGroup]="reweightForm">
+ <div class="modal-body">
+ <div class="row">
+ <label for="weight"
+ class="cd-col-form-label">Weight</label>
+ <div class="cd-col-form-input">
+ <input id="weight"
+ class="form-control"
+ type="number"
+ step="0.1"
+ formControlName="weight"
+ min="0"
+ max="1"
+ [value]="currentWeight">
+ <span class="invalid-feedback"
+ *ngIf="weight.errors">
+ <span *ngIf="weight.errors?.required"
+ i18n>This field is required.</span>
+ <span *ngIf="weight.errors?.max || weight.errors?.min"
+ i18n>The value needs to be between 0 and 1.</span>
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="reweight()"
+ [form]="reweightForm"
+ [submitText]="actionLabels.REWEIGHT"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts
new file mode 100644
index 000000000..41e05021e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts
@@ -0,0 +1,56 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { of } from 'rxjs';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { BackButtonComponent } from '~/app/shared/components/back-button/back-button.component';
+import { ModalComponent } from '~/app/shared/components/modal/modal.component';
+import { SubmitButtonComponent } from '~/app/shared/components/submit-button/submit-button.component';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdReweightModalComponent } from './osd-reweight-modal.component';
+
+describe('OsdReweightModalComponent', () => {
+ let component: OsdReweightModalComponent;
+ let fixture: ComponentFixture<OsdReweightModalComponent>;
+
+ configureTestBed({
+ imports: [ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule],
+ declarations: [
+ OsdReweightModalComponent,
+ ModalComponent,
+ SubmitButtonComponent,
+ BackButtonComponent
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [OsdService, NgbActiveModal, CdFormBuilder]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdReweightModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should call OsdService::reweight() on submit', () => {
+ component.osdId = 1;
+ component.reweightForm.get('weight').setValue(0.5);
+
+ const osdServiceSpy = spyOn(TestBed.inject(OsdService), 'reweight').and.callFake(() =>
+ of(true)
+ );
+ component.reweight();
+
+ expect(osdServiceSpy.calls.count()).toBe(1);
+ expect(osdServiceSpy.calls.first().args).toEqual([1, 0.5]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts
new file mode 100644
index 000000000..acbdb2d8f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts
@@ -0,0 +1,43 @@
+import { Component, OnInit } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+@Component({
+ selector: 'cd-osd-reweight-modal',
+ templateUrl: './osd-reweight-modal.component.html',
+ styleUrls: ['./osd-reweight-modal.component.scss']
+})
+export class OsdReweightModalComponent implements OnInit {
+ currentWeight = 1;
+ osdId: number;
+ reweightForm: CdFormGroup;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ public activeModal: NgbActiveModal,
+ private osdService: OsdService,
+ private fb: CdFormBuilder
+ ) {}
+
+ get weight() {
+ return this.reweightForm.get('weight');
+ }
+
+ ngOnInit() {
+ this.reweightForm = this.fb.group({
+ weight: this.fb.control(this.currentWeight, [Validators.required])
+ });
+ }
+
+ reweight() {
+ this.osdService
+ .reweight(this.osdId, this.reweightForm.value.weight)
+ .subscribe(() => this.activeModal.close());
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html
new file mode 100644
index 000000000..568c700fa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html
@@ -0,0 +1,22 @@
+<cd-modal [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>OSDs {deep, select, true {Deep } other {}}Scrub</span>
+
+ <ng-container class="modal-content">
+ <form name="scrubForm"
+ #formDir="ngForm"
+ [formGroup]="scrubForm"
+ novalidate>
+ <div class="modal-body">
+ <p i18n>You are about to apply a {deep, select, true {deep } other {}}scrub to
+ the OSD(s): <strong>{{ selected | join }}</strong>.</p>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="scrub()"
+ [form]="scrubForm"
+ [submitText]="actionLabels.UPDATE"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.spec.ts
new file mode 100644
index 000000000..c65dad0de
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.spec.ts
@@ -0,0 +1,50 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { JoinPipe } from '~/app/shared/pipes/join.pipe';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdScrubModalComponent } from './osd-scrub-modal.component';
+
+describe('OsdScrubModalComponent', () => {
+ let component: OsdScrubModalComponent;
+ let fixture: ComponentFixture<OsdScrubModalComponent>;
+
+ const fakeService = {
+ list: () => {
+ return new Promise(() => undefined);
+ },
+ scrub: () => {
+ return new Promise(() => undefined);
+ },
+ scrub_many: () => {
+ return new Promise(() => undefined);
+ }
+ };
+
+ configureTestBed({
+ imports: [ReactiveFormsModule],
+ declarations: [OsdScrubModalComponent, JoinPipe],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ NgbActiveModal,
+ JoinPipe,
+ { provide: OsdService, useValue: fakeService },
+ { provide: NotificationService, useValue: fakeService }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OsdScrubModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.ts
new file mode 100644
index 000000000..b2f636708
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.ts
@@ -0,0 +1,52 @@
+import { Component, OnInit } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { forkJoin } from 'rxjs';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { JoinPipe } from '~/app/shared/pipes/join.pipe';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-osd-scrub-modal',
+ templateUrl: './osd-scrub-modal.component.html',
+ styleUrls: ['./osd-scrub-modal.component.scss']
+})
+export class OsdScrubModalComponent implements OnInit {
+ deep: boolean;
+ scrubForm: FormGroup;
+ selected: any[] = [];
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ private osdService: OsdService,
+ private notificationService: NotificationService,
+ private joinPipe: JoinPipe
+ ) {}
+
+ ngOnInit() {
+ this.scrubForm = new FormGroup({});
+ }
+
+ scrub() {
+ forkJoin(this.selected.map((id: any) => this.osdService.scrub(id, this.deep))).subscribe(
+ () => {
+ const operation = this.deep ? 'Deep scrub' : 'Scrub';
+
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`${operation} was initialized in the following OSD(s): ${this.joinPipe.transform(
+ this.selected
+ )}`
+ );
+
+ this.activeModal.close();
+ },
+ () => this.activeModal.close()
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.html
new file mode 100644
index 000000000..278bc4ddc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.html
@@ -0,0 +1,41 @@
+<cd-prometheus-tabs></cd-prometheus-tabs>
+
+<cd-alert-panel *ngIf="!isAlertmanagerConfigured"
+ type="info"
+ i18n>To see all active Prometheus alerts, please provide
+ the URL to the API of Prometheus' Alertmanager as described
+ in the <cd-doc section="prometheus"></cd-doc>.</cd-alert-panel>
+
+<cd-table *ngIf="isAlertmanagerConfigured"
+ [data]="prometheusAlertService.alerts"
+ [columns]="columns"
+ identifier="fingerprint"
+ [forceIdentifier]="true"
+ [customCss]="customCss"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+
+ <cd-table-key-value cdTableDetail
+ *ngIf="expandedRow"
+ [renderObjects]="true"
+ [hideEmpty]="true"
+ [appendParentKey]="false"
+ [data]="expandedRow"
+ [customCss]="customCss"
+ [autoReload]="false">
+ </cd-table-key-value>
+</cd-table>
+
+<ng-template #externalLinkTpl
+ let-row="row"
+ let-value="value">
+ <a [href]="value"
+ target="_blank"><i [ngClass]="[icons.lineChart]"></i> Source</a>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.spec.ts
new file mode 100644
index 000000000..7b10c20aa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.spec.ts
@@ -0,0 +1,103 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { ClusterModule } from '~/app/ceph/cluster/cluster.module';
+import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
+import { CoreModule } from '~/app/core/core.module';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { ActiveAlertListComponent } from './active-alert-list.component';
+
+describe('ActiveAlertListComponent', () => {
+ let component: ActiveAlertListComponent;
+ let fixture: ComponentFixture<ActiveAlertListComponent>;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ NgbNavModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ SharedModule,
+ ClusterModule,
+ DashboardModule,
+ CephModule,
+ CoreModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ActiveAlertListComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should test all TableActions combinations', () => {
+ component.ngOnInit();
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create Silence'],
+ primary: {
+ multiple: 'Create Silence',
+ executing: 'Create Silence',
+ single: 'Create Silence',
+ no: 'Create Silence'
+ }
+ },
+ 'create,update': {
+ actions: ['Create Silence'],
+ primary: {
+ multiple: 'Create Silence',
+ executing: 'Create Silence',
+ single: 'Create Silence',
+ no: 'Create Silence'
+ }
+ },
+ 'create,delete': {
+ actions: ['Create Silence'],
+ primary: {
+ multiple: 'Create Silence',
+ executing: 'Create Silence',
+ single: 'Create Silence',
+ no: 'Create Silence'
+ }
+ },
+ create: {
+ actions: ['Create Silence'],
+ primary: {
+ multiple: 'Create Silence',
+ executing: 'Create Silence',
+ single: 'Create Silence',
+ no: 'Create Silence'
+ }
+ },
+ 'update,delete': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ },
+ update: { actions: [], primary: { multiple: '', executing: '', single: '', no: '' } },
+ delete: { actions: [], primary: { multiple: '', executing: '', single: '', no: '' } },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts
new file mode 100644
index 000000000..83888a555
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts
@@ -0,0 +1,101 @@
+import { Component, Inject, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { PrometheusListHelper } from '../prometheus-list-helper';
+
+const BASE_URL = 'silences'; // as only silence actions can be used
+
+@Component({
+ selector: 'cd-active-alert-list',
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
+ templateUrl: './active-alert-list.component.html',
+ styleUrls: ['./active-alert-list.component.scss']
+})
+export class ActiveAlertListComponent extends PrometheusListHelper implements OnInit {
+ @ViewChild('externalLinkTpl', { static: true })
+ externalLinkTpl: TemplateRef<any>;
+ columns: CdTableColumn[];
+ tableActions: CdTableAction[];
+ permission: Permission;
+ selection = new CdTableSelection();
+ icons = Icons;
+ customCss = {
+ 'badge badge-danger': 'active',
+ 'badge badge-warning': 'unprocessed',
+ 'badge badge-info': 'suppressed'
+ };
+
+ constructor(
+ // NotificationsComponent will refresh all alerts every 5s (No need to do it here as well)
+ private authStorageService: AuthStorageService,
+ public prometheusAlertService: PrometheusAlertService,
+ private urlBuilder: URLBuilderService,
+ private cdDatePipe: CdDatePipe,
+ @Inject(PrometheusService) prometheusService: PrometheusService
+ ) {
+ super(prometheusService);
+ this.permission = this.authStorageService.getPermissions().prometheus;
+ this.tableActions = [
+ {
+ permission: 'create',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection || selection.first().cdExecuting,
+ icon: Icons.add,
+ routerLink: () =>
+ '/monitoring' + this.urlBuilder.getCreateFrom(this.selection.first().fingerprint),
+ name: $localize`Create Silence`
+ }
+ ];
+ }
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'labels.alertname',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Job`,
+ prop: 'labels.job',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Severity`,
+ prop: 'labels.severity'
+ },
+ {
+ name: $localize`State`,
+ prop: 'status.state',
+ cellTransformation: CellTemplate.classAdding
+ },
+ {
+ name: $localize`Started`,
+ prop: 'startsAt',
+ pipe: this.cdDatePipe
+ },
+ {
+ name: $localize`URL`,
+ prop: 'generatorURL',
+ sortable: false,
+ cellTemplate: this.externalLinkTpl
+ }
+ ];
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list-helper.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list-helper.ts
new file mode 100644
index 000000000..c1a594908
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list-helper.ts
@@ -0,0 +1,24 @@
+import { Directive, OnInit } from '@angular/core';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+
+@Directive()
+// tslint:disable-next-line: directive-class-suffix
+export class PrometheusListHelper extends ListWithDetails implements OnInit {
+ public isPrometheusConfigured = false;
+ public isAlertmanagerConfigured = false;
+
+ constructor(protected prometheusService: PrometheusService) {
+ super();
+ }
+
+ ngOnInit() {
+ this.prometheusService.ifAlertmanagerConfigured(() => {
+ this.isAlertmanagerConfigured = true;
+ });
+ this.prometheusService.ifPrometheusConfigured(() => {
+ this.isPrometheusConfigured = true;
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html
new file mode 100644
index 000000000..fd3967ce6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html
@@ -0,0 +1,18 @@
+<ul ngbNav
+ #nav="ngbNav"
+ [activeId]="router.url"
+ (navChange)="router.navigate([$event.nextId])"
+ class="nav-tabs">
+ <li ngbNavItem="/monitoring/active-alerts">
+ <a ngbNavLink
+ i18n>Active Alerts</a>
+ </li>
+ <li ngbNavItem="/monitoring/alerts">
+ <a ngbNavLink
+ i18n>Alerts</a>
+ </li>
+ <li ngbNavItem="/monitoring/silences">
+ <a ngbNavLink
+ i18n>Silences</a>
+ </li>
+</ul>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts
new file mode 100644
index 000000000..675063413
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { PrometheusTabsComponent } from './prometheus-tabs.component';
+
+describe('PrometheusTabsComponent', () => {
+ let component: PrometheusTabsComponent;
+ let fixture: ComponentFixture<PrometheusTabsComponent>;
+
+ configureTestBed({
+ imports: [RouterTestingModule, NgbNavModule],
+ declarations: [PrometheusTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PrometheusTabsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts
new file mode 100644
index 000000000..4011770d4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts
@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'cd-prometheus-tabs',
+ templateUrl: './prometheus-tabs.component.html',
+ styleUrls: ['./prometheus-tabs.component.scss']
+})
+export class PrometheusTabsComponent {
+ constructor(public router: Router) {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.html
new file mode 100644
index 000000000..4ae7e8a31
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.html
@@ -0,0 +1,22 @@
+<cd-prometheus-tabs></cd-prometheus-tabs>
+
+<cd-alert-panel *ngIf="!isPrometheusConfigured"
+ type="info"
+ i18n>To see all configured Prometheus alerts, please
+ provide the URL to the API of Prometheus as described in
+ the <cd-doc section="prometheus"></cd-doc>.</cd-alert-panel>
+
+<cd-table *ngIf="isPrometheusConfigured"
+ [data]="prometheusAlertService.rules"
+ [columns]="columns"
+ [selectionType]="'single'"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-key-value cdTableDetail
+ *ngIf="expandedRow"
+ [data]="expandedRow"
+ [renderObjects]="true"
+ [hideKeys]="hideKeys">
+ </cd-table-key-value>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.spec.ts
new file mode 100644
index 000000000..ada139e6d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.spec.ts
@@ -0,0 +1,40 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { PrometheusTabsComponent } from '../prometheus-tabs/prometheus-tabs.component';
+import { RulesListComponent } from './rules-list.component';
+
+describe('RulesListComponent', () => {
+ let component: RulesListComponent;
+ let fixture: ComponentFixture<RulesListComponent>;
+
+ configureTestBed({
+ declarations: [RulesListComponent, PrometheusTabsComponent],
+ imports: [
+ HttpClientTestingModule,
+ SharedModule,
+ NgbNavModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [PrometheusService, SettingsService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RulesListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.ts
new file mode 100644
index 000000000..325520d11
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/rules-list/rules-list.component.ts
@@ -0,0 +1,44 @@
+import { Component, Inject, OnInit } from '@angular/core';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { PrometheusRule } from '~/app/shared/models/prometheus-alerts';
+import { DurationPipe } from '~/app/shared/pipes/duration.pipe';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+import { PrometheusListHelper } from '../prometheus-list-helper';
+
+@Component({
+ selector: 'cd-rules-list',
+ templateUrl: './rules-list.component.html',
+ styleUrls: ['./rules-list.component.scss']
+})
+export class RulesListComponent extends PrometheusListHelper implements OnInit {
+ columns: CdTableColumn[];
+ expandedRow: PrometheusRule;
+
+ /**
+ * Hide active alerts in details of alerting rules as they are already shown
+ * in the 'active alerts' table. Also hide the 'type' column as the type is
+ * always supposed to be 'alerting'.
+ */
+ hideKeys = ['alerts', 'type'];
+
+ constructor(
+ public prometheusAlertService: PrometheusAlertService,
+ @Inject(PrometheusService) prometheusService: PrometheusService
+ ) {
+ super(prometheusService);
+ }
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.columns = [
+ { prop: 'name', name: $localize`Name` },
+ { prop: 'labels.severity', name: $localize`Severity` },
+ { prop: 'group', name: $localize`Group` },
+ { prop: 'duration', name: $localize`Duration`, pipe: new DurationPipe() },
+ { prop: 'query', name: $localize`Query`, isHidden: true },
+ { prop: 'annotations.description', name: $localize`Description` }
+ ];
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html
new file mode 100644
index 000000000..02f04a06a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html
@@ -0,0 +1,224 @@
+<ng-template #matcherTpl
+ let-matcher="matcher"
+ let-index="index">
+ <div class="input-group my-2">
+ <ng-container *ngFor="let config of matcherConfig">
+ <div class="input-group-prepend">
+ <span class="input-group-text"
+ [ngbTooltip]="config.tooltip">
+ <i [ngClass]="[config.icon]"></i>
+ </span>
+ </div>
+
+ <ng-container *ngIf="config.attribute !== 'isRegex'">
+ <input type="text"
+ id="matcher-{{config.attribute}}-{{index}}"
+ class="form-control"
+ [value]="matcher[config.attribute]"
+ disabled
+ readonly>
+ </ng-container>
+
+ <ng-container *ngIf="config.attribute === 'isRegex'">
+ <div class="input-group-append">
+ <div class="input-group-text">
+ <input type="checkbox"
+ id="matcher-{{config.attribute}}-{{index}}"
+ [checked]="matcher[config.attribute]"
+ disabled
+ readonly>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+
+ <!-- Matcher actions -->
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ id="matcher-edit-{{index}}"
+ i18n-ngbTooltip
+ ngbTooltip="Edit"
+ (click)="showMatcherModal(index)">
+ <i [ngClass]="[icons.edit]"></i>
+ </button>
+ <button type="button"
+ class="btn btn-light"
+ id="matcher-delete-{{index}}"
+ i18n-ngbTooltip
+ ngbTooltip="Delete"
+ (click)="deleteMatcher(index)">
+ <i [ngClass]="[icons.trash]"></i>
+ </button>
+ </span>
+ </div>
+ <span class="help-block"></span>
+</ng-template>
+
+<div class="cd-col-form">
+ <form #formDir="ngForm"
+ [formGroup]="form"
+ class="form"
+ name="form"
+ novalidate>
+ <div class="card">
+ <div class="card-header">
+ <span i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+ <cd-helper *ngIf="edit"
+ i18n>Editing a silence will expire the old silence and recreate it as a new silence</cd-helper>
+ </div>
+
+ <!-- Creator -->
+ <div class="card-body">
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="created-by"
+ i18n>Creator</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ formControlName="createdBy"
+ id="created-by"
+ name="created-by"
+ type="text">
+ <span *ngIf="form.showError('createdBy', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Comment -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="comment"
+ i18n>Comment</label>
+ <div class="cd-col-form-input">
+ <textarea class="form-control"
+ formControlName="comment"
+ id="comment"
+ name="comment"
+ type="text">
+ </textarea>
+ <span *ngIf="form.showError('comment', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Start time -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="starts-at">
+ <span class="required"
+ i18n>Start time</span>
+ <cd-helper i18n>If the start time lies in the past the creation time will be used</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ formControlName="startsAt"
+ [ngbPopover]="popStart"
+ triggers="manual"
+ #ps="ngbPopover"
+ (click)="ps.open()"
+ (keypress)="ps.close()">
+ <span *ngIf="form.showError('startsAt', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Duration -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="duration"
+ i18n>Duration</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ formControlName="duration"
+ id="duration"
+ name="duration"
+ type="text">
+ <span *ngIf="form.showError('duration', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- End time -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="ends-at"
+ i18n>End time</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ formControlName="endsAt"
+ [ngbPopover]="popEnd"
+ triggers="manual"
+ #pe="ngbPopover"
+ (click)="pe.open()"
+ (keypress)="pe.close()">
+ <span *ngIf="form.showError('endsAt', formDir, 'required')"
+ class="invalid-feedback"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Matchers -->
+ <fieldset>
+ <legend class="required"
+ i18n>Matchers</legend>
+
+ <div class="cd-col-form-offset">
+ <h5 *ngIf="matchers.length === 0"
+ [ngClass]="{'text-warning': !formDir.submitted, 'text-danger': formDir.submitted}">
+ <strong i18n>A silence requires at least one matcher</strong>
+ </h5>
+
+ <span *ngFor="let matcher of matchers; let i=index;">
+ <ng-container *ngTemplateOutlet="matcherTpl; context:{index: i, matcher: matcher}"></ng-container>
+ </span>
+
+ <div class="row">
+ <div class="col-12">
+ <button type="button"
+ id="add-matcher"
+ class="btn btn-light float-right my-3"
+ [ngClass]="{'btn-warning': formDir.submitted && matchers.length === 0 }"
+ (click)="showMatcherModal()">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add matcher</ng-container>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div *ngIf="matchers.length && matcherMatch"
+ class="cd-col-form-offset {{matcherMatch.cssClass}}"
+ id="match-state">
+ <span class="text-muted {{matcherMatch.cssClass}}">
+ {{ matcherMatch.status }}
+ </span>
+ </div>
+ </fieldset>
+ </div>
+
+ <div class="card-footer">
+ <div class="text-right">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="form"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </div>
+ </div>
+ </form>
+</div>
+
+<ng-template #popStart>
+ <cd-date-time-picker [control]="form.get('startsAt')"
+ [hasSeconds]="false"></cd-date-time-picker>
+</ng-template>
+
+
+<ng-template #popEnd>
+ <cd-date-time-picker [control]="form.get('endsAt')"
+ [hasSeconds]="false"></cd-date-time-picker>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss
new file mode 100644
index 000000000..fb52450d4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss
@@ -0,0 +1,3 @@
+textarea {
+ resize: vertical;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts
new file mode 100644
index 000000000..418983150
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts
@@ -0,0 +1,598 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute, Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import moment from 'moment';
+import { ToastrModule } from 'ngx-toastr';
+import { of, throwError } from 'rxjs';
+
+import { DashboardNotFoundError } from '~/app/core/error/error';
+import { ErrorComponent } from '~/app/core/error/error.component';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { AlertmanagerSilence } from '~/app/shared/models/alertmanager-silence';
+import { Permission } from '~/app/shared/models/permissions';
+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 { SharedModule } from '~/app/shared/shared.module';
+import {
+ configureTestBed,
+ FixtureHelper,
+ FormHelper,
+ PrometheusHelper
+} from '~/testing/unit-test-helper';
+import { SilenceFormComponent } from './silence-form.component';
+
+describe('SilenceFormComponent', () => {
+ // SilenceFormComponent specific
+ let component: SilenceFormComponent;
+ let fixture: ComponentFixture<SilenceFormComponent>;
+ let form: CdFormGroup;
+ // Spied on
+ let prometheusService: PrometheusService;
+ let authStorageService: AuthStorageService;
+ let notificationService: NotificationService;
+ let router: Router;
+ // Spies
+ let rulesSpy: jasmine.Spy;
+ let ifPrometheusSpy: jasmine.Spy;
+ // Helper
+ let prometheus: PrometheusHelper;
+ let formHelper: FormHelper;
+ let fixtureH: FixtureHelper;
+ let params: Record<string, any>;
+ // Date mocking related
+ const baseTime = '2022-02-22 00:00';
+ const beginningDate = '2022-02-22T00:00:12.35';
+ let prometheusPermissions: Permission;
+
+ const routes: Routes = [{ path: '404', component: ErrorComponent }];
+ configureTestBed({
+ declarations: [ErrorComponent, SilenceFormComponent],
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule.withRoutes(routes),
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbTooltipModule,
+ NgbPopoverModule,
+ ReactiveFormsModule
+ ],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: { params: { subscribe: (fn: Function) => fn(params) } }
+ }
+ ]
+ });
+
+ const createMatcher = (name: string, value: any, isRegex: boolean) => ({ name, value, isRegex });
+
+ const addMatcher = (name: string, value: any, isRegex: boolean) =>
+ component['setMatcher'](createMatcher(name, value, isRegex));
+
+ const callInit = () =>
+ fixture.ngZone.run(() => {
+ component['init']();
+ });
+
+ const changeAction = (action: string) => {
+ const modes = {
+ add: '/monitoring/silences/add',
+ alertAdd: '/monitoring/silences/add/alert0',
+ recreate: '/monitoring/silences/recreate/someExpiredId',
+ edit: '/monitoring/silences/edit/someNotExpiredId'
+ };
+ Object.defineProperty(router, 'url', { value: modes[action] });
+ callInit();
+ };
+
+ beforeEach(() => {
+ params = {};
+ spyOn(Date, 'now').and.returnValue(new Date(beginningDate));
+
+ prometheus = new PrometheusHelper();
+ prometheusService = TestBed.inject(PrometheusService);
+ spyOn(prometheusService, 'getAlerts').and.callFake(() => {
+ const name = _.split(router.url, '/').pop();
+ return of([prometheus.createAlert(name)]);
+ });
+ ifPrometheusSpy = spyOn(prometheusService, 'ifPrometheusConfigured').and.callFake((fn) => fn());
+ rulesSpy = spyOn(prometheusService, 'getRules').and.callFake(() =>
+ of({
+ groups: [
+ {
+ file: '',
+ interval: 0,
+ name: '',
+ rules: [
+ prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]),
+ prometheus.createRule('alert1', 'someSeverity', []),
+ prometheus.createRule('alert2', 'someOtherSeverity', [
+ prometheus.createAlert('alert2')
+ ])
+ ]
+ }
+ ]
+ })
+ );
+
+ router = TestBed.inject(Router);
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+
+ authStorageService = TestBed.inject(AuthStorageService);
+ spyOn(authStorageService, 'getUsername').and.returnValue('someUser');
+
+ spyOn(authStorageService, 'getPermissions').and.callFake(() => ({
+ prometheus: prometheusPermissions
+ }));
+ prometheusPermissions = new Permission(['update', 'delete', 'read', 'create']);
+ fixture = TestBed.createComponent(SilenceFormComponent);
+ fixtureH = new FixtureHelper(fixture);
+ component = fixture.componentInstance;
+ form = component.form;
+ formHelper = new FormHelper(form);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(_.isArray(component.rules)).toBeTruthy();
+ });
+
+ it('should have set the logged in user name as creator', () => {
+ expect(component.form.getValue('createdBy')).toBe('someUser');
+ });
+
+ it('should call disablePrometheusConfig on error calling getRules', () => {
+ spyOn(prometheusService, 'disablePrometheusConfig');
+ rulesSpy.and.callFake(() => throwError({}));
+ callInit();
+ expect(component.rules).toEqual([]);
+ expect(prometheusService.disablePrometheusConfig).toHaveBeenCalled();
+ });
+
+ it('should remind user if prometheus is not set when it is not configured', () => {
+ ifPrometheusSpy.and.callFake((_x: any, fn: Function) => fn());
+ callInit();
+ expect(component.rules).toEqual([]);
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.info,
+ 'Please add your Prometheus host to the dashboard configuration and refresh the page',
+ undefined,
+ undefined,
+ 'Prometheus'
+ );
+ });
+
+ describe('throw error for not allowed users', () => {
+ let navigateSpy: jasmine.Spy;
+
+ const expectError = (action: string, redirected: boolean) => {
+ Object.defineProperty(router, 'url', { value: action });
+ if (redirected) {
+ expect(() => callInit()).toThrowError(DashboardNotFoundError);
+ } else {
+ expect(() => callInit()).not.toThrowError();
+ }
+ navigateSpy.calls.reset();
+ };
+
+ beforeEach(() => {
+ navigateSpy = spyOn(router, 'navigate').and.stub();
+ });
+
+ it('should throw error if not allowed', () => {
+ prometheusPermissions = new Permission(['delete', 'read']);
+ expectError('add', true);
+ expectError('alertAdd', true);
+ });
+
+ it('should throw error if user does not have minimum permissions to create silences', () => {
+ prometheusPermissions = new Permission(['update', 'delete', 'read']);
+ expectError('add', true);
+ prometheusPermissions = new Permission(['update', 'delete', 'create']);
+ expectError('recreate', true);
+ });
+
+ it('should throw error if user does not have minimum permissions to update silences', () => {
+ prometheusPermissions = new Permission(['delete', 'read']);
+ expectError('edit', true);
+ prometheusPermissions = new Permission(['create', 'delete', 'update']);
+ expectError('edit', true);
+ });
+
+ it('does not throw error if user has minimum permissions to create silences', () => {
+ prometheusPermissions = new Permission(['create', 'read']);
+ expectError('add', false);
+ expectError('alertAdd', false);
+ expectError('recreate', false);
+ });
+
+ it('does not throw error if user has minimum permissions to update silences', () => {
+ prometheusPermissions = new Permission(['read', 'create']);
+ expectError('edit', false);
+ });
+ });
+
+ describe('choose the right action', () => {
+ const expectMode = (routerMode: string, edit: boolean, recreate: boolean, action: string) => {
+ changeAction(routerMode);
+ expect(component.recreate).toBe(recreate);
+ expect(component.edit).toBe(edit);
+ expect(component.action).toBe(action);
+ };
+
+ beforeEach(() => {
+ spyOn(prometheusService, 'getSilences').and.callFake(() => {
+ const id = _.split(router.url, '/').pop();
+ return of([prometheus.createSilence(id)]);
+ });
+ });
+
+ it('should have no special action activate by default', () => {
+ expectMode('add', false, false, 'Create');
+ expect(prometheusService.getSilences).not.toHaveBeenCalled();
+ expect(component.form.value).toEqual({
+ comment: null,
+ createdBy: 'someUser',
+ duration: '2h',
+ startsAt: baseTime,
+ endsAt: '2022-02-22 02:00'
+ });
+ });
+
+ it('should be in edit action if route includes edit', () => {
+ params = { id: 'someNotExpiredId' };
+ expectMode('edit', true, false, 'Edit');
+ expect(prometheusService.getSilences).toHaveBeenCalled();
+ expect(component.form.value).toEqual({
+ comment: `A comment for ${params.id}`,
+ createdBy: `Creator of ${params.id}`,
+ duration: '1d',
+ startsAt: '2022-02-22 22:22',
+ endsAt: '2022-02-23 22:22'
+ });
+ expect(component.matchers).toEqual([createMatcher('job', 'someJob', true)]);
+ });
+
+ it('should be in recreation action if route includes recreate', () => {
+ params = { id: 'someExpiredId' };
+ expectMode('recreate', false, true, 'Recreate');
+ expect(prometheusService.getSilences).toHaveBeenCalled();
+ expect(component.form.value).toEqual({
+ comment: `A comment for ${params.id}`,
+ createdBy: `Creator of ${params.id}`,
+ duration: '2h',
+ startsAt: baseTime,
+ endsAt: '2022-02-22 02:00'
+ });
+ expect(component.matchers).toEqual([createMatcher('job', 'someJob', true)]);
+ });
+
+ it('adds matchers based on the label object of the alert with the given id', () => {
+ params = { id: 'alert0' };
+ expectMode('alertAdd', false, false, 'Create');
+ expect(prometheusService.getSilences).not.toHaveBeenCalled();
+ expect(prometheusService.getAlerts).toHaveBeenCalled();
+ expect(component.matchers).toEqual([
+ createMatcher('alertname', 'alert0', false),
+ createMatcher('instance', 'someInstance', false),
+ createMatcher('job', 'someJob', false),
+ createMatcher('severity', 'someSeverity', false)
+ ]);
+ expect(component.matcherMatch).toEqual({
+ cssClass: 'has-success',
+ status: 'Matches 1 rule with 1 active alert.'
+ });
+ });
+ });
+
+ describe('time', () => {
+ const changeEndDate = (text: string) => component.form.patchValue({ endsAt: text });
+ const changeStartDate = (text: string) => component.form.patchValue({ startsAt: text });
+
+ it('have all dates set at beginning', () => {
+ expect(form.getValue('startsAt')).toEqual(baseTime);
+ expect(form.getValue('duration')).toBe('2h');
+ expect(form.getValue('endsAt')).toEqual('2022-02-22 02:00');
+ });
+
+ describe('on start date change', () => {
+ it('changes end date on start date change if it exceeds it', fakeAsync(() => {
+ changeStartDate('2022-02-28 04:05');
+ expect(form.getValue('duration')).toEqual('2h');
+ expect(form.getValue('endsAt')).toEqual('2022-02-28 06:05');
+
+ changeStartDate('2022-12-31 22:00');
+ expect(form.getValue('duration')).toEqual('2h');
+ expect(form.getValue('endsAt')).toEqual('2023-01-01 00:00');
+ }));
+
+ it('changes duration if start date does not exceed end date ', fakeAsync(() => {
+ changeStartDate('2022-02-22 00:45');
+ expect(form.getValue('duration')).toEqual('1h 15m');
+ expect(form.getValue('endsAt')).toEqual('2022-02-22 02:00');
+ }));
+
+ it('should raise invalid start date error', fakeAsync(() => {
+ changeStartDate('No valid date');
+ formHelper.expectError('startsAt', 'format');
+ expect(form.getValue('startsAt').toString()).toBe('No valid date');
+ expect(form.getValue('endsAt')).toEqual('2022-02-22 02:00');
+ }));
+ });
+
+ describe('on duration change', () => {
+ it('changes end date if duration is changed', () => {
+ formHelper.setValue('duration', '15m');
+ expect(form.getValue('endsAt')).toEqual('2022-02-22 00:15');
+ formHelper.setValue('duration', '5d 23h');
+ expect(form.getValue('endsAt')).toEqual('2022-02-27 23:00');
+ });
+ });
+
+ describe('on end date change', () => {
+ it('changes duration on end date change if it exceeds start date', fakeAsync(() => {
+ changeEndDate('2022-02-28 04:05');
+ expect(form.getValue('duration')).toEqual('6d 4h 5m');
+ expect(form.getValue('startsAt')).toEqual(baseTime);
+ }));
+
+ it('changes start date if end date happens before it', fakeAsync(() => {
+ changeEndDate('2022-02-21 02:00');
+ expect(form.getValue('duration')).toEqual('2h');
+ expect(form.getValue('startsAt')).toEqual('2022-02-21 00:00');
+ }));
+
+ it('should raise invalid end date error', fakeAsync(() => {
+ changeEndDate('No valid date');
+ formHelper.expectError('endsAt', 'format');
+ expect(form.getValue('endsAt').toString()).toBe('No valid date');
+ expect(form.getValue('startsAt')).toEqual(baseTime);
+ }));
+ });
+ });
+
+ it('should have a creator field', () => {
+ formHelper.expectValid('createdBy');
+ formHelper.expectErrorChange('createdBy', '', 'required');
+ formHelper.expectValidChange('createdBy', 'Mighty FSM');
+ });
+
+ it('should have a comment field', () => {
+ formHelper.expectError('comment', 'required');
+ formHelper.expectValidChange('comment', 'A pretty long comment');
+ });
+
+ it('should be a valid form if all inputs are filled and at least one matcher was added', () => {
+ expect(form.valid).toBeFalsy();
+ formHelper.expectValidChange('createdBy', 'Mighty FSM');
+ formHelper.expectValidChange('comment', 'A pretty long comment');
+ addMatcher('job', 'someJob', false);
+ expect(form.valid).toBeTruthy();
+ });
+
+ describe('matchers', () => {
+ const expectMatch = (helpText: string) => {
+ expect(fixtureH.getText('#match-state')).toBe(helpText);
+ };
+
+ it('should show the add matcher button', () => {
+ fixtureH.expectElementVisible('#add-matcher', true);
+ fixtureH.expectIdElementsVisible(
+ [
+ 'matcher-name-0',
+ 'matcher-value-0',
+ 'matcher-isRegex-0',
+ 'matcher-edit-0',
+ 'matcher-delete-0'
+ ],
+ false
+ );
+ expectMatch(null);
+ });
+
+ it('should show added matcher', () => {
+ addMatcher('job', 'someJob', true);
+ fixtureH.expectIdElementsVisible(
+ [
+ 'matcher-name-0',
+ 'matcher-value-0',
+ 'matcher-isRegex-0',
+ 'matcher-edit-0',
+ 'matcher-delete-0'
+ ],
+ true
+ );
+ expectMatch(null);
+ });
+
+ it('should show multiple matchers', () => {
+ addMatcher('severity', 'someSeverity', false);
+ addMatcher('alertname', 'alert0', false);
+ fixtureH.expectIdElementsVisible(
+ [
+ 'matcher-name-0',
+ 'matcher-value-0',
+ 'matcher-isRegex-0',
+ 'matcher-edit-0',
+ 'matcher-delete-0',
+ 'matcher-name-1',
+ 'matcher-value-1',
+ 'matcher-isRegex-1',
+ 'matcher-edit-1',
+ 'matcher-delete-1'
+ ],
+ true
+ );
+ expectMatch('Matches 1 rule with 1 active alert.');
+ });
+
+ it('should show the right matcher values', () => {
+ addMatcher('alertname', 'alert.*', true);
+ addMatcher('job', 'someJob', false);
+ fixture.detectChanges();
+ fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname');
+ fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert.*');
+ fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'true');
+ fixtureH.expectFormFieldToBe('#matcher-isRegex-1', 'false');
+ expectMatch(null);
+ });
+
+ it('should be able to edit a matcher', () => {
+ addMatcher('alertname', 'alert.*', true);
+ expectMatch(null);
+
+ const modalService = TestBed.inject(ModalService);
+ spyOn(modalService, 'show').and.callFake(() => {
+ return {
+ componentInstance: {
+ preFillControls: (matcher: any) => {
+ expect(matcher).toBe(component.matchers[0]);
+ },
+ submitAction: of({ name: 'alertname', value: 'alert0', isRegex: false })
+ }
+ };
+ });
+ fixtureH.clickElement('#matcher-edit-0');
+
+ fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname');
+ fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert0');
+ fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'false');
+ expectMatch('Matches 1 rule with 1 active alert.');
+ });
+
+ it('should be able to remove a matcher', () => {
+ addMatcher('alertname', 'alert0', false);
+ expectMatch('Matches 1 rule with 1 active alert.');
+ fixtureH.clickElement('#matcher-delete-0');
+ expect(component.matchers).toEqual([]);
+ fixtureH.expectIdElementsVisible(
+ ['matcher-name-0', 'matcher-value-0', 'matcher-isRegex-0'],
+ false
+ );
+ expectMatch(null);
+ });
+
+ it('should be able to remove a matcher and update the matcher text', () => {
+ addMatcher('alertname', 'alert0', false);
+ addMatcher('alertname', 'alert1', false);
+ expectMatch('Your matcher seems to match no currently defined rule or active alert.');
+ fixtureH.clickElement('#matcher-delete-1');
+ expectMatch('Matches 1 rule with 1 active alert.');
+ });
+
+ it('should show form as invalid if no matcher is set', () => {
+ expect(form.errors).toEqual({ matcherRequired: true });
+ });
+
+ it('should show form as valid if matcher was added', () => {
+ addMatcher('some name', 'some value', true);
+ expect(form.errors).toEqual(null);
+ });
+ });
+
+ describe('submit tests', () => {
+ const endsAt = '2022-02-22 02:00';
+ let silence: AlertmanagerSilence;
+ const silenceId = '50M3-10N6-1D';
+
+ const expectSuccessNotification = (titleStartsWith: string) =>
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `${titleStartsWith} silence ${silenceId}`,
+ undefined,
+ undefined,
+ 'Prometheus'
+ );
+
+ const fillAndSubmit = () => {
+ ['createdBy', 'comment'].forEach((attr) => {
+ formHelper.setValue(attr, silence[attr]);
+ });
+ silence.matchers.forEach((matcher) =>
+ addMatcher(matcher.name, matcher.value, matcher.isRegex)
+ );
+ component.submit();
+ };
+
+ beforeEach(() => {
+ spyOn(prometheusService, 'setSilence').and.callFake(() => of({ body: { silenceId } }));
+ spyOn(router, 'navigate').and.stub();
+ silence = {
+ createdBy: 'some creator',
+ comment: 'some comment',
+ startsAt: moment(baseTime).toISOString(),
+ endsAt: moment(endsAt).toISOString(),
+ matchers: [
+ {
+ name: 'some attribute name',
+ value: 'some value',
+ isRegex: false
+ },
+ {
+ name: 'job',
+ value: 'node-exporter',
+ isRegex: false
+ },
+ {
+ name: 'instance',
+ value: 'localhost:9100',
+ isRegex: false
+ },
+ {
+ name: 'alertname',
+ value: 'load_0',
+ isRegex: false
+ }
+ ]
+ };
+ });
+
+ // it('should not create a silence if the form is invalid', () => {
+ // component.submit();
+ // expect(notificationService.show).not.toHaveBeenCalled();
+ // expect(form.valid).toBeFalsy();
+ // expect(prometheusService.setSilence).not.toHaveBeenCalledWith(silence);
+ // expect(router.navigate).not.toHaveBeenCalled();
+ // });
+
+ // it('should route back to previous tab on success', () => {
+ // fillAndSubmit();
+ // expect(form.valid).toBeTruthy();
+ // expect(router.navigate).toHaveBeenCalledWith(['/monitoring'], { fragment: 'silences' });
+ // });
+
+ it('should create a silence', () => {
+ fillAndSubmit();
+ expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
+ expectSuccessNotification('Created');
+ });
+
+ it('should recreate a silence', () => {
+ component.recreate = true;
+ component.id = 'recreateId';
+ fillAndSubmit();
+ expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
+ expectSuccessNotification('Recreated');
+ });
+
+ it('should edit a silence', () => {
+ component.edit = true;
+ component.id = 'editId';
+ silence.id = component.id;
+ fillAndSubmit();
+ expect(prometheusService.setSilence).toHaveBeenCalledWith(silence);
+ expectSuccessNotification('Edited');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts
new file mode 100644
index 000000000..b698e4958
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts
@@ -0,0 +1,340 @@
+import { Component } from '@angular/core';
+import { Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import moment from 'moment';
+
+import { DashboardNotFoundError } from '~/app/core/error/error';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { ActionLabelsI18n, SucceededActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import {
+ AlertmanagerSilence,
+ AlertmanagerSilenceMatcher,
+ AlertmanagerSilenceMatcherMatch
+} from '~/app/shared/models/alertmanager-silence';
+import { Permission } from '~/app/shared/models/permissions';
+import { AlertmanagerAlert, PrometheusRule } from '~/app/shared/models/prometheus-alerts';
+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 { PrometheusSilenceMatcherService } from '~/app/shared/services/prometheus-silence-matcher.service';
+import { TimeDiffService } from '~/app/shared/services/time-diff.service';
+import { SilenceMatcherModalComponent } from '../silence-matcher-modal/silence-matcher-modal.component';
+
+@Component({
+ selector: 'cd-prometheus-form',
+ templateUrl: './silence-form.component.html',
+ styleUrls: ['./silence-form.component.scss']
+})
+export class SilenceFormComponent {
+ icons = Icons;
+ permission: Permission;
+ form: CdFormGroup;
+ rules: PrometheusRule[];
+
+ recreate = false;
+ edit = false;
+ id: string;
+
+ action: string;
+ resource = $localize`silence`;
+
+ matchers: AlertmanagerSilenceMatcher[] = [];
+ matcherMatch: AlertmanagerSilenceMatcherMatch = undefined;
+ matcherConfig = [
+ {
+ tooltip: $localize`Attribute name`,
+ icon: this.icons.paragraph,
+ attribute: 'name'
+ },
+ {
+ tooltip: $localize`Value`,
+ icon: this.icons.terminal,
+ attribute: 'value'
+ },
+ {
+ tooltip: $localize`Regular expression`,
+ icon: this.icons.magic,
+ attribute: 'isRegex'
+ }
+ ];
+
+ datetimeFormat = 'YYYY-MM-DD HH:mm';
+
+ constructor(
+ private router: Router,
+ private authStorageService: AuthStorageService,
+ private formBuilder: CdFormBuilder,
+ private prometheusService: PrometheusService,
+ private notificationService: NotificationService,
+ private route: ActivatedRoute,
+ private timeDiff: TimeDiffService,
+ private modalService: ModalService,
+ private silenceMatcher: PrometheusSilenceMatcherService,
+ private actionLabels: ActionLabelsI18n,
+ private succeededLabels: SucceededActionLabelsI18n
+ ) {
+ this.init();
+ }
+
+ private init() {
+ this.chooseMode();
+ this.authenticate();
+ this.createForm();
+ this.setupDates();
+ this.getData();
+ }
+
+ private chooseMode() {
+ this.edit = this.router.url.startsWith('/monitoring/silences/edit');
+ this.recreate = this.router.url.startsWith('/monitoring/silences/recreate');
+ if (this.edit) {
+ this.action = this.actionLabels.EDIT;
+ } else if (this.recreate) {
+ this.action = this.actionLabels.RECREATE;
+ } else {
+ this.action = this.actionLabels.CREATE;
+ }
+ }
+
+ private authenticate() {
+ this.permission = this.authStorageService.getPermissions().prometheus;
+ const allowed =
+ this.permission.read && (this.edit ? this.permission.update : this.permission.create);
+ if (!allowed) {
+ throw new DashboardNotFoundError();
+ }
+ }
+
+ private createForm() {
+ const formatValidator = CdValidators.custom('format', (expiresAt: string) => {
+ const result = expiresAt === '' || moment(expiresAt, this.datetimeFormat).isValid();
+ return !result;
+ });
+ this.form = this.formBuilder.group(
+ {
+ startsAt: ['', [Validators.required, formatValidator]],
+ duration: ['2h', [Validators.min(1)]],
+ endsAt: ['', [Validators.required, formatValidator]],
+ createdBy: [this.authStorageService.getUsername(), [Validators.required]],
+ comment: [null, [Validators.required]]
+ },
+ {
+ validators: CdValidators.custom('matcherRequired', () => this.matchers.length === 0)
+ }
+ );
+ }
+
+ private setupDates() {
+ const now = moment().format(this.datetimeFormat);
+ this.form.silentSet('startsAt', now);
+ this.updateDate();
+ this.subscribeDateChanges();
+ }
+
+ private updateDate(updateStartDate?: boolean) {
+ const date = moment(
+ this.form.getValue(updateStartDate ? 'endsAt' : 'startsAt'),
+ this.datetimeFormat
+ ).toDate();
+ const next = this.timeDiff.calculateDate(date, this.form.getValue('duration'), updateStartDate);
+ if (next) {
+ const nextDate = moment(next).format(this.datetimeFormat);
+ this.form.silentSet(updateStartDate ? 'startsAt' : 'endsAt', nextDate);
+ }
+ }
+
+ private subscribeDateChanges() {
+ this.form.get('startsAt').valueChanges.subscribe(() => {
+ this.onDateChange();
+ });
+ this.form.get('duration').valueChanges.subscribe(() => {
+ this.updateDate();
+ });
+ this.form.get('endsAt').valueChanges.subscribe(() => {
+ this.onDateChange(true);
+ });
+ }
+
+ private onDateChange(updateStartDate?: boolean) {
+ const startsAt = moment(this.form.getValue('startsAt'), this.datetimeFormat);
+ const endsAt = moment(this.form.getValue('endsAt'), this.datetimeFormat);
+ if (startsAt.isBefore(endsAt)) {
+ this.updateDuration();
+ } else {
+ this.updateDate(updateStartDate);
+ }
+ }
+
+ private updateDuration() {
+ const startsAt = moment(this.form.getValue('startsAt'), this.datetimeFormat).toDate();
+ const endsAt = moment(this.form.getValue('endsAt'), this.datetimeFormat).toDate();
+ this.form.silentSet('duration', this.timeDiff.calculateDuration(startsAt, endsAt));
+ }
+
+ private getData() {
+ this.getRules();
+ this.getModeSpecificData();
+ }
+
+ private getRules() {
+ this.prometheusService.ifPrometheusConfigured(
+ () =>
+ this.prometheusService.getRules().subscribe(
+ (groups) => {
+ this.rules = groups['groups'].reduce(
+ (acc, group) => _.concat<PrometheusRule>(acc, group.rules),
+ []
+ );
+ },
+ () => {
+ this.prometheusService.disablePrometheusConfig();
+ this.rules = [];
+ }
+ ),
+ () => {
+ this.rules = [];
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`Please add your Prometheus host to the dashboard configuration and refresh the page`,
+ undefined,
+ undefined,
+ 'Prometheus'
+ );
+ }
+ );
+ }
+
+ private getModeSpecificData() {
+ this.route.params.subscribe((params: { id: string }) => {
+ if (!params.id) {
+ return;
+ }
+ if (this.edit || this.recreate) {
+ this.prometheusService.getSilences().subscribe((silences) => {
+ const silence = _.find(silences, ['id', params.id]);
+ if (!_.isUndefined(silence)) {
+ this.fillFormWithSilence(silence);
+ }
+ });
+ } else {
+ this.prometheusService.getAlerts().subscribe((alerts) => {
+ const alert = _.find(alerts, ['fingerprint', params.id]);
+ if (!_.isUndefined(alert)) {
+ this.fillFormByAlert(alert);
+ }
+ });
+ }
+ });
+ }
+
+ private fillFormWithSilence(silence: AlertmanagerSilence) {
+ this.id = silence.id;
+ if (this.edit) {
+ ['startsAt', 'endsAt'].forEach((attr) =>
+ this.form.silentSet(attr, moment(silence[attr]).format(this.datetimeFormat))
+ );
+ this.updateDuration();
+ }
+ ['createdBy', 'comment'].forEach((attr) => this.form.silentSet(attr, silence[attr]));
+ this.matchers = silence.matchers;
+ this.validateMatchers();
+ }
+
+ private validateMatchers() {
+ if (!this.rules) {
+ window.setTimeout(() => this.validateMatchers(), 100);
+ return;
+ }
+ this.matcherMatch = this.silenceMatcher.multiMatch(this.matchers, this.rules);
+ this.form.markAsDirty();
+ this.form.updateValueAndValidity();
+ }
+
+ private fillFormByAlert(alert: AlertmanagerAlert) {
+ const labels = alert.labels;
+ Object.keys(labels).forEach((key) =>
+ this.setMatcher({
+ name: key,
+ value: labels[key],
+ isRegex: false
+ })
+ );
+ }
+
+ private setMatcher(matcher: AlertmanagerSilenceMatcher, index?: number) {
+ if (_.isNumber(index)) {
+ this.matchers[index] = matcher;
+ } else {
+ this.matchers.push(matcher);
+ }
+ this.validateMatchers();
+ }
+
+ showMatcherModal(index?: number) {
+ const modalRef = this.modalService.show(SilenceMatcherModalComponent);
+ const modalComponent = modalRef.componentInstance as SilenceMatcherModalComponent;
+ modalComponent.rules = this.rules;
+ if (_.isNumber(index)) {
+ modalComponent.editMode = true;
+ modalComponent.preFillControls(this.matchers[index]);
+ }
+ modalComponent.submitAction.subscribe((matcher: AlertmanagerSilenceMatcher) => {
+ this.setMatcher(matcher, index);
+ });
+ }
+
+ deleteMatcher(index: number) {
+ this.matchers.splice(index, 1);
+ this.validateMatchers();
+ }
+
+ submit() {
+ if (this.form.invalid) {
+ return;
+ }
+ this.prometheusService.setSilence(this.getSubmitData()).subscribe(
+ (resp) => {
+ this.router.navigate(['/monitoring/silences']);
+ this.notificationService.show(
+ NotificationType.success,
+ this.getNotificationTile(resp.body['silenceId']),
+ undefined,
+ undefined,
+ 'Prometheus'
+ );
+ },
+ () => this.form.setErrors({ cdSubmitButton: true })
+ );
+ }
+
+ private getSubmitData(): AlertmanagerSilence {
+ const payload = this.form.value;
+ delete payload.duration;
+ payload.startsAt = moment(payload.startsAt, this.datetimeFormat).toISOString();
+ payload.endsAt = moment(payload.endsAt, this.datetimeFormat).toISOString();
+ payload.matchers = this.matchers;
+ if (this.edit) {
+ payload.id = this.id;
+ }
+ return payload;
+ }
+
+ private getNotificationTile(id: string) {
+ let action;
+ if (this.edit) {
+ action = this.succeededLabels.EDITED;
+ } else if (this.recreate) {
+ action = this.succeededLabels.RECREATED;
+ } else {
+ action = this.succeededLabels.CREATED;
+ }
+ return `${action} ${this.resource} ${id}`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html
new file mode 100644
index 000000000..2997ff373
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html
@@ -0,0 +1,34 @@
+<cd-prometheus-tabs></cd-prometheus-tabs>
+
+<cd-alert-panel *ngIf="!isAlertmanagerConfigured"
+ type="info"
+ i18n>To enable Silences, please provide the URL to
+ the API of the Prometheus' Alertmanager as described in the
+ <cd-doc section="prometheus"></cd-doc>.</cd-alert-panel>
+
+<cd-table *ngIf="isAlertmanagerConfigured"
+ [data]="silences"
+ [columns]="columns"
+ [forceIdentifier]="true"
+ [customCss]="customCss"
+ [sorts]="sorts"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (fetchData)="refresh()"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-table-key-value cdTableDetail
+ *ngIf="expandedRow"
+ [renderObjects]="true"
+ [hideEmpty]="true"
+ [appendParentKey]="false"
+ [data]="expandedRow"
+ [customCss]="customCss"
+ [autoReload]="false">
+ </cd-table-key-value>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts
new file mode 100644
index 000000000..cc4b76c32
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts
@@ -0,0 +1,140 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { PrometheusTabsComponent } from '../prometheus-tabs/prometheus-tabs.component';
+import { SilenceListComponent } from './silence-list.component';
+
+describe('SilenceListComponent', () => {
+ let component: SilenceListComponent;
+ let fixture: ComponentFixture<SilenceListComponent>;
+ let prometheusService: PrometheusService;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule,
+ HttpClientTestingModule,
+ NgbNavModule
+ ],
+ declarations: [SilenceListComponent, PrometheusTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SilenceListComponent);
+ component = fixture.componentInstance;
+ prometheusService = TestBed.inject(PrometheusService);
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Recreate', 'Edit', 'Expire'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Recreate', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Recreate', 'Expire'],
+ primary: { multiple: 'Create', executing: 'Expire', single: 'Expire', no: 'Create' }
+ },
+ create: {
+ actions: ['Create', 'Recreate'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Expire'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Expire'],
+ primary: { multiple: 'Expire', executing: 'Expire', single: 'Expire', no: 'Expire' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ describe('expire silence', () => {
+ const setSelectedSilence = (silenceName: string) =>
+ (component.selection.selected = [{ id: silenceName }]);
+
+ const expireSilence = () => {
+ component.expireSilence();
+ const deletion: CriticalConfirmationModalComponent = component.modalRef.componentInstance;
+ // deletion.modalRef = new BsModalRef();
+ deletion.ngOnInit();
+ deletion.callSubmitAction();
+ };
+
+ const expectSilenceToExpire = (silenceId: string) => {
+ setSelectedSilence(silenceId);
+ expireSilence();
+ expect(prometheusService.expireSilence).toHaveBeenCalledWith(silenceId);
+ };
+
+ beforeEach(() => {
+ const mockObservable = () => of([]);
+ spyOn(component, 'refresh').and.callFake(mockObservable);
+ spyOn(prometheusService, 'expireSilence').and.callFake(mockObservable);
+ spyOn(TestBed.inject(ModalService), 'show').and.callFake((deletionClass, config) => {
+ return {
+ componentInstance: Object.assign(new deletionClass(), config)
+ };
+ });
+ });
+
+ it('should expire a silence', () => {
+ const notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+ expectSilenceToExpire('someSilenceId');
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ 'Expired Silence someSilenceId',
+ undefined,
+ undefined,
+ 'Prometheus'
+ );
+ });
+
+ it('should refresh after expiring a silence', () => {
+ expectSilenceToExpire('someId');
+ expect(component.refresh).toHaveBeenCalledTimes(1);
+ expectSilenceToExpire('someOtherId');
+ expect(component.refresh).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts
new file mode 100644
index 000000000..c351a64e5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts
@@ -0,0 +1,191 @@
+import { Component, Inject } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { SortDirection, SortPropDir } from '@swimlane/ngx-datatable';
+import { Observable, Subscriber } from 'rxjs';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n, SucceededActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { AlertmanagerSilence } from '~/app/shared/models/alertmanager-silence';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+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 { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { PrometheusListHelper } from '../prometheus-list-helper';
+
+const BASE_URL = 'monitoring/silences';
+
+@Component({
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
+ selector: 'cd-silences-list',
+ templateUrl: './silence-list.component.html',
+ styleUrls: ['./silence-list.component.scss']
+})
+export class SilenceListComponent extends PrometheusListHelper {
+ silences: AlertmanagerSilence[] = [];
+ columns: CdTableColumn[];
+ tableActions: CdTableAction[];
+ permission: Permission;
+ selection = new CdTableSelection();
+ modalRef: NgbModalRef;
+ customCss = {
+ 'badge badge-danger': 'active',
+ 'badge badge-warning': 'pending',
+ 'badge badge-default': 'expired'
+ };
+ sorts: SortPropDir[] = [{ prop: 'endsAt', dir: SortDirection.desc }];
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private cdDatePipe: CdDatePipe,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ private urlBuilder: URLBuilderService,
+ private actionLabels: ActionLabelsI18n,
+ private succeededLabels: SucceededActionLabelsI18n,
+ @Inject(PrometheusService) prometheusService: PrometheusService
+ ) {
+ super(prometheusService);
+ this.permission = this.authStorageService.getPermissions().prometheus;
+ const selectionExpired = (selection: CdTableSelection) =>
+ selection.first() && selection.first().status && selection.first().status.state === 'expired';
+ this.tableActions = [
+ {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection,
+ name: this.actionLabels.CREATE
+ },
+ {
+ permission: 'create',
+ canBePrimary: (selection: CdTableSelection) =>
+ selection.hasSingleSelection && selectionExpired(selection),
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection ||
+ selection.first().cdExecuting ||
+ (selection.first().cdExecuting && selectionExpired(selection)) ||
+ !selectionExpired(selection),
+ icon: Icons.copy,
+ routerLink: () => this.urlBuilder.getRecreate(this.selection.first().id),
+ name: this.actionLabels.RECREATE
+ },
+ {
+ permission: 'update',
+ icon: Icons.edit,
+ canBePrimary: (selection: CdTableSelection) =>
+ selection.hasSingleSelection && !selectionExpired(selection),
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection ||
+ selection.first().cdExecuting ||
+ (selection.first().cdExecuting && !selectionExpired(selection)) ||
+ selectionExpired(selection),
+ routerLink: () => this.urlBuilder.getEdit(this.selection.first().id),
+ name: this.actionLabels.EDIT
+ },
+ {
+ permission: 'delete',
+ icon: Icons.trash,
+ canBePrimary: (selection: CdTableSelection) =>
+ selection.hasSingleSelection && !selectionExpired(selection),
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection ||
+ selection.first().cdExecuting ||
+ selectionExpired(selection),
+ click: () => this.expireSilence(),
+ name: this.actionLabels.EXPIRE
+ }
+ ];
+ this.columns = [
+ {
+ name: $localize`ID`,
+ prop: 'id',
+ flexGrow: 3
+ },
+ {
+ name: $localize`Created by`,
+ prop: 'createdBy',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Started`,
+ prop: 'startsAt',
+ pipe: this.cdDatePipe
+ },
+ {
+ name: $localize`Updated`,
+ prop: 'updatedAt',
+ pipe: this.cdDatePipe
+ },
+ {
+ name: $localize`Ends`,
+ prop: 'endsAt',
+ pipe: this.cdDatePipe
+ },
+ {
+ name: $localize`Status`,
+ prop: 'status.state',
+ cellTransformation: CellTemplate.classAdding
+ }
+ ];
+ }
+
+ refresh() {
+ this.prometheusService.ifAlertmanagerConfigured(() => {
+ this.prometheusService.getSilences().subscribe(
+ (silences) => {
+ this.silences = silences;
+ },
+ () => {
+ this.prometheusService.disableAlertmanagerConfig();
+ }
+ );
+ });
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ expireSilence() {
+ const id = this.selection.first().id;
+ const i18nSilence = $localize`Silence`;
+ const applicationName = 'Prometheus';
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: i18nSilence,
+ itemNames: [id],
+ actionDescription: this.actionLabels.EXPIRE,
+ submitActionObservable: () =>
+ new Observable((observer: Subscriber<any>) => {
+ this.prometheusService.expireSilence(id).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ `${this.succeededLabels.EXPIRED} ${i18nSilence} ${id}`,
+ undefined,
+ undefined,
+ applicationName
+ );
+ },
+ (resp) => {
+ resp['application'] = applicationName;
+ observer.error(resp);
+ },
+ () => {
+ observer.complete();
+ this.refresh();
+ }
+ );
+ })
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html
new file mode 100644
index 000000000..db89adc53
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html
@@ -0,0 +1,85 @@
+<cd-modal [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>{editMode, select, true {Edit} other {Add}} Matcher</span>
+
+ <ng-container class="modal-content">
+ <form class="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="modal-body">
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="name"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="name"
+ formControlName="name"
+ name="name">
+ <option [ngValue]="null"
+ i18n>-- Select an attribute to match against --</option>
+ <option *ngFor="let attribute of nameAttributes"
+ [value]="attribute">
+ {{ attribute }}
+ </option>
+ </select>
+ <span class="help-block"
+ *ngIf="form.showError('name', formDir, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Value -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="value"
+ i18n>Value</label>
+ <div class="cd-col-form-input">
+ <input id="value"
+ (focus)="valueFocus.next($any($event).target.value)"
+ (click)="valueClick.next($any($event).target.value)"
+ container="body"
+ class="form-control"
+ type="text"
+ [ngbTypeahead]="search"
+ formControlName="value">
+ <span *ngIf="form.showError('value', formDir, 'required')"
+ class="help-block"
+ i18n>This field is required!</span>
+ </div>
+ <div *ngIf="form.getValue('value') && !form.getValue('isRegex') && matcherMatch"
+ class="cd-col-form-offset {{matcherMatch.cssClass}}"
+ id="match-state">
+ <span class="text-muted {{matcherMatch.cssClass}}">
+ {{matcherMatch.status}}
+ </span>
+ </div>
+ </div>
+
+ <!-- isRegex -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ formControlName="isRegex"
+ name="is-regex"
+ id="is-regex">
+ <label for="is-regex"
+ class="custom-control-label"
+ i18n>Use regular expression</label>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="form"
+ [submitText]="getMode()"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts
new file mode 100644
index 000000000..c9bfce9c1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts
@@ -0,0 +1,209 @@
+import { HttpClientTestingModule } 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, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { of } from 'rxjs';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import {
+ configureTestBed,
+ FixtureHelper,
+ FormHelper,
+ PrometheusHelper
+} from '~/testing/unit-test-helper';
+import { SilenceMatcherModalComponent } from './silence-matcher-modal.component';
+
+describe('SilenceMatcherModalComponent', () => {
+ let component: SilenceMatcherModalComponent;
+ let fixture: ComponentFixture<SilenceMatcherModalComponent>;
+
+ let formH: FormHelper;
+ let fixtureH: FixtureHelper;
+ let prometheus: PrometheusHelper;
+
+ configureTestBed({
+ declarations: [SilenceMatcherModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ SharedModule,
+ NgbTypeaheadModule,
+ RouterTestingModule,
+ ReactiveFormsModule
+ ],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SilenceMatcherModalComponent);
+ component = fixture.componentInstance;
+
+ fixtureH = new FixtureHelper(fixture);
+ formH = new FormHelper(component.form);
+ prometheus = new PrometheusHelper();
+
+ component.rules = [
+ prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]),
+ prometheus.createRule('alert1', 'someSeverity', [])
+ ];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have a name field', () => {
+ formH.expectError('name', 'required');
+ formH.expectValidChange('name', 'alertname');
+ });
+
+ it('should only allow a specific set of name attributes', () => {
+ expect(component.nameAttributes).toEqual(['alertname', 'instance', 'job', 'severity']);
+ });
+
+ it('should autocomplete a list based on the set name', () => {
+ const expectations = {
+ alertname: ['alert0', 'alert1'],
+ instance: ['someInstance'],
+ job: ['someJob'],
+ severity: ['someSeverity']
+ };
+ Object.keys(expectations).forEach((key) => {
+ formH.setValue('name', key);
+ expect(component.possibleValues).toEqual(expectations[key]);
+ });
+ });
+
+ describe('test rule matching', () => {
+ const expectMatch = (name: string, value: string, helpText: string) => {
+ component.preFillControls({
+ name: name,
+ value: value,
+ isRegex: false
+ });
+ expect(fixtureH.getText('#match-state')).toBe(helpText);
+ };
+
+ it('should match no rule and no alert', () => {
+ expectMatch(
+ 'alertname',
+ 'alert',
+ 'Your matcher seems to match no currently defined rule or active alert.'
+ );
+ });
+
+ it('should match a rule with no alert', () => {
+ expectMatch('alertname', 'alert1', 'Matches 1 rule with no active alerts.');
+ });
+
+ it('should match a rule and an alert', () => {
+ expectMatch('alertname', 'alert0', 'Matches 1 rule with 1 active alert.');
+ });
+
+ it('should match multiple rules and an alert', () => {
+ expectMatch('severity', 'someSeverity', 'Matches 2 rules with 1 active alert.');
+ });
+
+ it('should match multiple rules and multiple alerts', () => {
+ component.rules[1].alerts.push(null);
+ expectMatch('severity', 'someSeverity', 'Matches 2 rules with 2 active alerts.');
+ });
+
+ it('should not show match-state if regex is checked', () => {
+ fixtureH.expectElementVisible('#match-state', false);
+ formH.setValue('name', 'severity');
+ formH.setValue('value', 'someSeverity');
+ fixtureH.expectElementVisible('#match-state', true);
+ formH.setValue('isRegex', true);
+ fixtureH.expectElementVisible('#match-state', false);
+ });
+ });
+
+ it('should only enable value field if name was set', () => {
+ const value = component.form.get('value');
+ expect(value.disabled).toBeTruthy();
+ formH.setValue('name', component.nameAttributes[0]);
+ expect(value.enabled).toBeTruthy();
+ formH.setValue('name', null);
+ expect(value.disabled).toBeTruthy();
+ });
+
+ it('should have a value field', () => {
+ formH.setValue('name', component.nameAttributes[0]);
+ formH.expectError('value', 'required');
+ formH.expectValidChange('value', 'alert0');
+ });
+
+ it('should test preFillControls', () => {
+ const controlValues = {
+ name: 'alertname',
+ value: 'alert0',
+ isRegex: false
+ };
+ component.preFillControls(controlValues);
+ expect(component.form.value).toEqual(controlValues);
+ });
+
+ it('should test submit', (done) => {
+ const controlValues = {
+ name: 'alertname',
+ value: 'alert0',
+ isRegex: false
+ };
+ component.preFillControls(controlValues);
+ component.submitAction.subscribe((resp: object) => {
+ expect(resp).toEqual(controlValues);
+ done();
+ });
+ component.onSubmit();
+ });
+
+ describe('typeahead', () => {
+ let equality: { [key: string]: boolean };
+ let expectations: { [key: string]: string[] };
+
+ const search = (s: string) => {
+ Object.keys(expectations).forEach((key) => {
+ formH.setValue('name', key);
+ component.search(of(s)).subscribe((result) => {
+ // Expect won't fail the test inside subscribe
+ equality[key] = _.isEqual(result, expectations[key]);
+ });
+ expect(equality[key]).toBeTruthy();
+ });
+ };
+
+ beforeEach(() => {
+ equality = {
+ alertname: false,
+ instance: false,
+ job: false,
+ severity: false
+ };
+ expectations = {
+ alertname: ['alert0', 'alert1'],
+ instance: ['someInstance'],
+ job: ['someJob'],
+ severity: ['someSeverity']
+ };
+ });
+
+ it('should show all values on name switch', () => {
+ search('');
+ });
+
+ it('should search for "some"', () => {
+ expectations['alertname'] = [];
+ search('some');
+ });
+
+ it('should search for "er"', () => {
+ expectations['instance'] = [];
+ expectations['job'] = [];
+ search('er');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts
new file mode 100644
index 000000000..bdd616ce9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts
@@ -0,0 +1,107 @@
+import { Component, EventEmitter, Output, ViewChild } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { merge, Observable, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import {
+ AlertmanagerSilenceMatcher,
+ AlertmanagerSilenceMatcherMatch
+} from '~/app/shared/models/alertmanager-silence';
+import { PrometheusRule } from '~/app/shared/models/prometheus-alerts';
+import { PrometheusSilenceMatcherService } from '~/app/shared/services/prometheus-silence-matcher.service';
+
+@Component({
+ selector: 'cd-silence-matcher-modal',
+ templateUrl: './silence-matcher-modal.component.html',
+ styleUrls: ['./silence-matcher-modal.component.scss']
+})
+export class SilenceMatcherModalComponent {
+ @ViewChild(NgbTypeahead, { static: true })
+ typeahead: NgbTypeahead;
+ @Output()
+ submitAction = new EventEmitter();
+
+ form: CdFormGroup;
+ editMode = false;
+ rules: PrometheusRule[];
+ nameAttributes = ['alertname', 'instance', 'job', 'severity'];
+ possibleValues: string[] = [];
+ matcherMatch: AlertmanagerSilenceMatcherMatch = undefined;
+
+ // For typeahead usage
+ valueClick = new Subject<string>();
+ valueFocus = new Subject<string>();
+ search = (text$: Observable<string>) => {
+ return merge(
+ text$.pipe(debounceTime(200), distinctUntilChanged()),
+ this.valueFocus,
+ this.valueClick.pipe(filter(() => !this.typeahead.isPopupOpen()))
+ ).pipe(
+ map((term) =>
+ (term === ''
+ ? this.possibleValues
+ : this.possibleValues.filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1)
+ ).slice(0, 10)
+ )
+ );
+ };
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ private silenceMatcher: PrometheusSilenceMatcherService,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.createForm();
+ this.subscribeToChanges();
+ }
+
+ private createForm() {
+ this.form = this.formBuilder.group({
+ name: [null, [Validators.required]],
+ value: [{ value: '', disabled: true }, [Validators.required]],
+ isRegex: new FormControl(false)
+ });
+ }
+
+ private subscribeToChanges() {
+ this.form.get('name').valueChanges.subscribe((name) => {
+ if (name === null) {
+ this.form.get('value').disable();
+ return;
+ }
+ this.setPossibleValues(name);
+ this.form.get('value').enable();
+ });
+ this.form.get('value').valueChanges.subscribe((value) => {
+ const values = this.form.value;
+ values.value = value; // Isn't the current value at this stage
+ this.matcherMatch = this.silenceMatcher.singleMatch(values, this.rules);
+ });
+ }
+
+ private setPossibleValues(name: string) {
+ this.possibleValues = _.sortedUniq(
+ this.rules.map((r) => _.get(r, this.silenceMatcher.getAttributePath(name))).filter((x) => x)
+ );
+ }
+
+ getMode() {
+ return this.editMode ? this.actionLabels.EDIT : this.actionLabels.ADD;
+ }
+
+ preFillControls(matcher: AlertmanagerSilenceMatcher) {
+ this.form.setValue(matcher);
+ }
+
+ onSubmit() {
+ this.submitAction.emit(this.form.value);
+ this.activeModal.close();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.spec.ts
new file mode 100644
index 000000000..588744aa6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.spec.ts
@@ -0,0 +1,78 @@
+import { PlacementPipe } from './placement.pipe';
+
+describe('PlacementPipe', () => {
+ const pipe = new PlacementPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms to no spec', () => {
+ expect(pipe.transform(undefined)).toBe('no spec');
+ });
+
+ it('transforms to unmanaged', () => {
+ expect(pipe.transform({ unmanaged: true })).toBe('unmanaged');
+ });
+
+ it('transforms placement (1)', () => {
+ expect(
+ pipe.transform({
+ placement: {
+ hosts: ['mon0']
+ }
+ })
+ ).toBe('mon0');
+ });
+
+ it('transforms placement (2)', () => {
+ expect(
+ pipe.transform({
+ placement: {
+ hosts: ['mon0', 'mgr0']
+ }
+ })
+ ).toBe('mon0;mgr0');
+ });
+
+ it('transforms placement (3)', () => {
+ expect(
+ pipe.transform({
+ placement: {
+ count: 1
+ }
+ })
+ ).toBe('count:1');
+ });
+
+ it('transforms placement (4)', () => {
+ expect(
+ pipe.transform({
+ placement: {
+ label: 'foo'
+ }
+ })
+ ).toBe('label:foo');
+ });
+
+ it('transforms placement (5)', () => {
+ expect(
+ pipe.transform({
+ placement: {
+ host_pattern: 'abc.ceph.xyz.com'
+ }
+ })
+ ).toBe('abc.ceph.xyz.com');
+ });
+
+ it('transforms placement (6)', () => {
+ expect(
+ pipe.transform({
+ placement: {
+ count: 2,
+ hosts: ['mon0', 'mgr0']
+ }
+ })
+ ).toBe('mon0;mgr0;count:2');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.ts
new file mode 100644
index 000000000..5aee65890
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/placement.pipe.ts
@@ -0,0 +1,41 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'placement'
+})
+export class PlacementPipe implements PipeTransform {
+ /**
+ * Convert the placement configuration into human readable form.
+ * The output is equal to the column 'PLACEMENT' in 'ceph orch ls'.
+ * @param serviceSpec The service specification to process.
+ * @return The placement configuration as human readable string.
+ */
+ transform(serviceSpec: object | undefined): string {
+ if (_.isUndefined(serviceSpec)) {
+ return $localize`no spec`;
+ }
+ if (_.get(serviceSpec, 'unmanaged', false)) {
+ return $localize`unmanaged`;
+ }
+ const kv: Array<any> = [];
+ const hosts: Array<string> = _.get(serviceSpec, 'placement.hosts');
+ const count: number = _.get(serviceSpec, 'placement.count');
+ const label: string = _.get(serviceSpec, 'placement.label');
+ const hostPattern: string = _.get(serviceSpec, 'placement.host_pattern');
+ if (_.isArray(hosts)) {
+ kv.push(...hosts);
+ }
+ if (_.isNumber(count)) {
+ kv.push($localize`count:${count}`);
+ }
+ if (_.isString(label)) {
+ kv.push($localize`label:${label}`);
+ }
+ if (_.isString(hostPattern)) {
+ kv.push(hostPattern);
+ }
+ return kv.join(';');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html
new file mode 100644
index 000000000..fc076a185
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html
@@ -0,0 +1,102 @@
+<cd-orchestrator-doc-panel *ngIf="showDocPanel"></cd-orchestrator-doc-panel>
+
+<div *ngIf="flag === 'hostDetails'; else serviceDetailsTpl">
+ <ng-container *ngTemplateOutlet="serviceDaemonDetailsTpl"></ng-container>
+</div>
+
+<ng-template #serviceDetailsTpl>
+ <ng-container>
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="service-details">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <ng-container *ngTemplateOutlet="serviceDaemonDetailsTpl"></ng-container>
+ </ng-template>
+ </li>
+ <li ngbNavItem="service_events">
+ <a ngbNavLink
+ i18n>Service Events</a>
+ <ng-template ngbNavContent>
+ <cd-table *ngIf="hasOrchestrator"
+ #serviceTable
+ [data]="services"
+ [columns]="serviceColumns"
+ columnMode="flex"
+ (fetchData)="getServices($event)">
+ </cd-table>
+ </ng-template>
+ </li>
+ </ul>
+ <div [ngbNavOutlet]="nav"></div>
+ </ng-container>
+</ng-template>
+
+<ng-template #statusTpl
+ let-row="row">
+ <span class="badge"
+ [ngClass]="row | pipeFunction:getStatusClass">
+ {{ row.status_desc }}
+ </span>
+</ng-template>
+
+<ng-template #listTpl
+ let-events="value">
+ <ul class="list-group list-group-flush"
+ *ngIf="events?.length else noEventsAvailable">
+ <li class="list-group-item"
+ *ngFor="let event of events; trackBy:trackByFn">
+ <b>{{ event.created | relativeDate }} - </b>
+ <span class="badge badge-info">{{ event.subject }}</span><br>
+ <span *ngIf="event.level === 'INFO'">
+ <i [ngClass]="[icons.infoCircle]"
+ aria-hidden="true"></i>
+ </span>
+ <span *ngIf="event.level === 'ERROR'">
+ <i [ngClass]="[icons.warning]"
+ aria-hidden="true"></i>
+ </span>
+ {{ event.message }}
+ </li>
+ </ul>
+ <ng-template #noEventsAvailable>
+ <div *ngIf="events?.length === 0"
+ class="list-group-item">
+ <span>No data available</span>
+ </div>
+ </ng-template>
+</ng-template>
+
+<ng-template #serviceDaemonDetailsTpl>
+ <cd-table *ngIf="hasOrchestrator"
+ #daemonsTable
+ [data]="daemons"
+ selectionType="single"
+ [columns]="columns"
+ columnMode="flex"
+ identifier="daemon_name"
+ (fetchData)="getDaemons($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions id="service-daemon-list-actions"
+ class="table-actions"
+ [selection]="selection"
+ [permission]="permissions.hosts"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </cd-table>
+</ng-template>
+
+<ng-template #cpuTpl
+ let-row="row">
+ <cd-usage-bar [total]="total"
+ [calculatePerc]="false"
+ [used]="row.cpu_percentage"
+ [isBinary]="false"
+ [warningThreshold]="warningThreshold"
+ [errorThreshold]="errorThreshold">
+ </cd-usage-bar>
+</ng-template>
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.scss
new file mode 100644
index 000000000..a0d91c704
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.scss
@@ -0,0 +1,14 @@
+@use './src/styles/vendor/variables' as vv;
+
+.fa-info-circle {
+ color: vv.$info;
+}
+
+.fa-exclamation-triangle {
+ color: vv.$danger;
+}
+
+.list-group-item {
+ background-color: transparent;
+ border-width: 0;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts
new file mode 100644
index 000000000..31739a7c2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.spec.ts
@@ -0,0 +1,253 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import _ from 'lodash';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CoreModule } from '~/app/core/core.module';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ServiceDaemonListComponent } from './service-daemon-list.component';
+
+describe('ServiceDaemonListComponent', () => {
+ let component: ServiceDaemonListComponent;
+ let fixture: ComponentFixture<ServiceDaemonListComponent>;
+
+ const daemons = [
+ {
+ hostname: 'osd0',
+ container_id: '003c10beafc8c27b635bcdfed1ed832e4c1005be89bb1bb05ad4cc6c2b98e41b',
+ container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
+ container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
+ daemon_id: '3',
+ daemon_type: 'osd',
+ daemon_name: 'osd.3',
+ version: '15.1.0-1174-g16a11f7',
+ memory_usage: '17.7',
+ cpu_percentage: '3.54%',
+ status: 1,
+ status_desc: 'running',
+ last_refresh: '2020-02-25T04:33:26.465699',
+ events: [
+ { created: '2020-02-24T04:33:26.465699' },
+ { created: '2020-02-25T04:33:26.465699' },
+ { created: '2020-02-26T04:33:26.465699' }
+ ]
+ },
+ {
+ hostname: 'osd0',
+ container_id: 'baeec41a01374b3ed41016d542d19aef4a70d69c27274f271e26381a0cc58e7a',
+ container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
+ container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
+ daemon_id: '4',
+ daemon_type: 'osd',
+ daemon_name: 'osd.4',
+ version: '15.1.0-1174-g16a11f7',
+ memory_usage: '17.7',
+ cpu_percentage: '3.54%',
+ status: 1,
+ status_desc: 'running',
+ last_refresh: '2020-02-25T04:33:26.465822',
+ events: []
+ },
+ {
+ hostname: 'osd0',
+ container_id: '8483de277e365bea4365cee9e1f26606be85c471e4da5d51f57e4b85a42c616e',
+ container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
+ container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
+ daemon_id: '5',
+ daemon_type: 'osd',
+ daemon_name: 'osd.5',
+ version: '15.1.0-1174-g16a11f7',
+ memory_usage: '17.7',
+ cpu_percentage: '3.54%',
+ status: 1,
+ status_desc: 'running',
+ last_refresh: '2020-02-25T04:33:26.465886',
+ events: []
+ },
+ {
+ hostname: 'mon0',
+ container_id: '6ca0574f47e300a6979eaf4e7c283a8c4325c2235ae60358482fc4cd58844a21',
+ container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
+ container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
+ daemon_id: 'a',
+ daemon_name: 'mon.a',
+ daemon_type: 'mon',
+ version: '15.1.0-1174-g16a11f7',
+ memory_usage: '17.7',
+ cpu_percentage: '3.54%',
+ status: 1,
+ status_desc: 'running',
+ last_refresh: '2020-02-25T04:33:26.465886',
+ events: []
+ }
+ ];
+
+ const services = [
+ {
+ service_type: 'osd',
+ service_name: 'osd',
+ status: {
+ container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
+ container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
+ size: 3,
+ running: 3,
+ last_refresh: '2020-02-25T04:33:26.465699'
+ },
+ events: '2021-03-22T07:34:48.582163Z service:osd [INFO] "service was created"'
+ },
+ {
+ service_type: 'crash',
+ service_name: 'crash',
+ status: {
+ container_image_id: 'e70344c77bcbf3ee389b9bf5128f635cf95f3d59e005c5d8e67fc19bcc74ed23',
+ container_image_name: 'docker.io/ceph/daemon-base:latest-master-devel',
+ size: 1,
+ running: 1,
+ last_refresh: '2020-02-25T04:33:26.465766'
+ },
+ events: '2021-03-22T07:34:48.582163Z service:osd [INFO] "service was created"'
+ }
+ ];
+
+ const getDaemonsByHostname = (hostname?: string) => {
+ return hostname ? _.filter(daemons, { hostname: hostname }) : daemons;
+ };
+
+ const getDaemonsByServiceName = (serviceName?: string) => {
+ return serviceName ? _.filter(daemons, { daemon_type: serviceName }) : daemons;
+ };
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ CephModule,
+ CoreModule,
+ NgxPipeFunctionModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ServiceDaemonListComponent);
+ component = fixture.componentInstance;
+ const hostService = TestBed.inject(HostService);
+ const cephServiceService = TestBed.inject(CephServiceService);
+ spyOn(hostService, 'getDaemons').and.callFake(() =>
+ of(getDaemonsByHostname(component.hostname))
+ );
+ spyOn(cephServiceService, 'getDaemons').and.callFake(() =>
+ of(getDaemonsByServiceName(component.serviceName))
+ );
+ spyOn(cephServiceService, 'list').and.returnValue(of(services));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should list daemons by host', () => {
+ component.hostname = 'mon0';
+ component.getDaemons(new CdTableFetchDataContext(() => undefined));
+ expect(component.daemons.length).toBe(1);
+ });
+
+ it('should list daemons by service', () => {
+ component.serviceName = 'osd';
+ component.getDaemons(new CdTableFetchDataContext(() => undefined));
+ expect(component.daemons.length).toBe(3);
+ });
+
+ it('should list services', () => {
+ component.getServices(new CdTableFetchDataContext(() => undefined));
+ expect(component.services.length).toBe(2);
+ });
+
+ it('should not display doc panel if orchestrator is available', () => {
+ expect(component.showDocPanel).toBeFalsy();
+ });
+
+ it('should call daemon action', () => {
+ const daemon = daemons[0];
+ component.selection.selected = [daemon];
+ component['daemonService'].action = jest.fn(() => of());
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ component.daemonAction(action);
+ expect(component['daemonService'].action).toHaveBeenCalledWith(daemon.daemon_name, action);
+ }
+ });
+
+ it('should disable daemon actions', () => {
+ const daemon = {
+ daemon_type: 'osd',
+ status_desc: 'running'
+ };
+
+ const states = {
+ start: true,
+ stop: false,
+ restart: false,
+ redeploy: false
+ };
+ const expectBool = (toExpect: boolean, arg: boolean) => {
+ if (toExpect === true) {
+ expect(arg).toBeTruthy();
+ } else {
+ expect(arg).toBeFalsy();
+ }
+ };
+
+ component.selection.selected = [daemon];
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expectBool(states[action], component.actionDisabled(action));
+ }
+
+ daemon.status_desc = 'stopped';
+ states.start = false;
+ states.stop = true;
+ component.selection.selected = [daemon];
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expectBool(states[action], component.actionDisabled(action));
+ }
+ });
+
+ it('should disable daemon actions in mgr and mon daemon', () => {
+ const daemon = {
+ daemon_type: 'mgr',
+ status_desc: 'running'
+ };
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expect(component.actionDisabled(action)).toBeTruthy();
+ }
+ daemon.daemon_type = 'mon';
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expect(component.actionDisabled(action)).toBeTruthy();
+ }
+ });
+
+ it('should disable daemon actions if no selection', () => {
+ component.selection.selected = [];
+ for (const action of ['start', 'stop', 'restart', 'redeploy']) {
+ expect(component.actionDisabled(action)).toBeTruthy();
+ }
+ });
+
+ it('should sort daemons events', () => {
+ component.sortDaemonEvents();
+ const daemon = daemons[0];
+ for (let i = 1; i < daemon.events.length; i++) {
+ const t1 = new Date(daemon.events[i - 1].created).getTime();
+ const t2 = new Date(daemon.events[i].created).getTime();
+ expect(t1 >= t2).toBeTruthy();
+ }
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts
new file mode 100644
index 000000000..d1c2f9cc3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.ts
@@ -0,0 +1,347 @@
+import {
+ AfterViewInit,
+ Component,
+ Input,
+ OnChanges,
+ OnDestroy,
+ OnInit,
+ QueryList,
+ TemplateRef,
+ ViewChild,
+ ViewChildren
+} from '@angular/core';
+
+import _ from 'lodash';
+import { Observable, Subscription } from 'rxjs';
+import { take } from 'rxjs/operators';
+
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { DaemonService } from '~/app/shared/api/daemon.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Daemon } from '~/app/shared/models/daemon.interface';
+import { Permissions } from '~/app/shared/models/permissions';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { RelativeDatePipe } from '~/app/shared/pipes/relative-date.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-service-daemon-list',
+ templateUrl: './service-daemon-list.component.html',
+ styleUrls: ['./service-daemon-list.component.scss']
+})
+export class ServiceDaemonListComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
+ @ViewChild('statusTpl', { static: true })
+ statusTpl: TemplateRef<any>;
+
+ @ViewChild('listTpl', { static: true })
+ listTpl: TemplateRef<any>;
+
+ @ViewChild('cpuTpl', { static: true })
+ cpuTpl: TemplateRef<any>;
+
+ @ViewChildren('daemonsTable')
+ daemonsTableTpls: QueryList<TemplateRef<TableComponent>>;
+
+ @Input()
+ serviceName?: string;
+
+ @Input()
+ hostname?: string;
+
+ @Input()
+ hiddenColumns: string[] = [];
+
+ @Input()
+ flag?: string;
+
+ total = 100;
+
+ warningThreshold = 0.8;
+
+ errorThreshold = 0.9;
+
+ icons = Icons;
+
+ daemons: Daemon[] = [];
+ services: Array<CephServiceSpec> = [];
+ columns: CdTableColumn[] = [];
+ serviceColumns: CdTableColumn[] = [];
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permissions: Permissions;
+
+ hasOrchestrator = false;
+ showDocPanel = false;
+
+ private daemonsTable: TableComponent;
+ private daemonsTableTplsSub: Subscription;
+ private serviceSub: Subscription;
+
+ constructor(
+ private hostService: HostService,
+ private cephServiceService: CephServiceService,
+ private orchService: OrchestratorService,
+ private relativeDatePipe: RelativeDatePipe,
+ private dimlessBinary: DimlessBinaryPipe,
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private daemonService: DaemonService,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnInit() {
+ this.permissions = this.authStorageService.getPermissions();
+ this.tableActions = [
+ {
+ permission: 'update',
+ icon: Icons.start,
+ click: () => this.daemonAction('start'),
+ name: this.actionLabels.START,
+ disable: () => this.actionDisabled('start')
+ },
+ {
+ permission: 'update',
+ icon: Icons.stop,
+ click: () => this.daemonAction('stop'),
+ name: this.actionLabels.STOP,
+ disable: () => this.actionDisabled('stop')
+ },
+ {
+ permission: 'update',
+ icon: Icons.restart,
+ click: () => this.daemonAction('restart'),
+ name: this.actionLabels.RESTART,
+ disable: () => this.actionDisabled('restart')
+ },
+ {
+ permission: 'update',
+ icon: Icons.deploy,
+ click: () => this.daemonAction('redeploy'),
+ name: this.actionLabels.REDEPLOY,
+ disable: () => this.actionDisabled('redeploy')
+ }
+ ];
+ this.columns = [
+ {
+ name: $localize`Hostname`,
+ prop: 'hostname',
+ flexGrow: 2,
+ filterable: true
+ },
+ {
+ name: $localize`Daemon name`,
+ prop: 'daemon_name',
+ flexGrow: 1,
+ filterable: true
+ },
+ {
+ name: $localize`Version`,
+ prop: 'version',
+ flexGrow: 1,
+ filterable: true
+ },
+ {
+ name: $localize`Status`,
+ prop: 'status_desc',
+ flexGrow: 1,
+ filterable: true,
+ cellTemplate: this.statusTpl
+ },
+ {
+ name: $localize`Last Refreshed`,
+ prop: 'last_refresh',
+ pipe: this.relativeDatePipe,
+ flexGrow: 1
+ },
+ {
+ name: $localize`CPU Usage`,
+ prop: 'cpu_percentage',
+ flexGrow: 1,
+ cellTemplate: this.cpuTpl
+ },
+ {
+ name: $localize`Memory Usage`,
+ prop: 'memory_usage',
+ flexGrow: 1,
+ pipe: this.dimlessBinary,
+ cellClass: 'text-right'
+ },
+ {
+ name: $localize`Daemon Events`,
+ prop: 'events',
+ flexGrow: 2,
+ cellTemplate: this.listTpl
+ }
+ ];
+
+ this.serviceColumns = [
+ {
+ name: $localize`Service Name`,
+ prop: 'service_name',
+ flexGrow: 2,
+ filterable: true
+ },
+ {
+ name: $localize`Service Type`,
+ prop: 'service_type',
+ flexGrow: 1,
+ filterable: true
+ },
+ {
+ name: $localize`Service Events`,
+ prop: 'events',
+ flexGrow: 5,
+ cellTemplate: this.listTpl
+ }
+ ];
+
+ this.orchService.status().subscribe((data: { available: boolean }) => {
+ this.hasOrchestrator = data.available;
+ this.showDocPanel = !data.available;
+ });
+
+ this.columns = this.columns.filter((col: any) => {
+ return !this.hiddenColumns.includes(col.prop);
+ });
+ }
+
+ ngOnChanges() {
+ if (!_.isUndefined(this.daemonsTable)) {
+ this.daemonsTable.reloadData();
+ }
+ }
+
+ ngAfterViewInit() {
+ this.daemonsTableTplsSub = this.daemonsTableTpls.changes.subscribe(
+ (tableRefs: QueryList<TableComponent>) => {
+ this.daemonsTable = tableRefs.first;
+ }
+ );
+ }
+
+ ngOnDestroy() {
+ if (this.daemonsTableTplsSub) {
+ this.daemonsTableTplsSub.unsubscribe();
+ }
+ if (this.serviceSub) {
+ this.serviceSub.unsubscribe();
+ }
+ }
+
+ getStatusClass(row: Daemon): string {
+ return _.get(
+ {
+ '-1': 'badge-danger',
+ '0': 'badge-warning',
+ '1': 'badge-success'
+ },
+ row.status,
+ 'badge-dark'
+ );
+ }
+
+ getDaemons(context: CdTableFetchDataContext) {
+ let observable: Observable<Daemon[]>;
+ if (this.hostname) {
+ observable = this.hostService.getDaemons(this.hostname);
+ } else if (this.serviceName) {
+ observable = this.cephServiceService.getDaemons(this.serviceName);
+ } else {
+ this.daemons = [];
+ return;
+ }
+ observable.subscribe(
+ (daemons: Daemon[]) => {
+ this.daemons = daemons;
+ this.sortDaemonEvents();
+ },
+ () => {
+ this.daemons = [];
+ context.error();
+ }
+ );
+ }
+
+ sortDaemonEvents() {
+ this.daemons.forEach((daemon: any) => {
+ daemon.events?.sort((event1: any, event2: any) => {
+ return new Date(event2.created).getTime() - new Date(event1.created).getTime();
+ });
+ });
+ }
+ getServices(context: CdTableFetchDataContext) {
+ this.serviceSub = this.cephServiceService.list(this.serviceName).subscribe(
+ (services: CephServiceSpec[]) => {
+ this.services = services;
+ },
+ () => {
+ this.services = [];
+ context.error();
+ }
+ );
+ }
+
+ trackByFn(_index: any, item: any) {
+ return item.created;
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ daemonAction(actionType: string) {
+ this.daemonService
+ .action(this.selection.first()?.daemon_name, actionType)
+ .pipe(take(1))
+ .subscribe({
+ next: (resp) => {
+ this.notificationService.show(
+ NotificationType.success,
+ `Daemon ${actionType} scheduled`,
+ resp.body.toString()
+ );
+ },
+ error: (resp) => {
+ this.notificationService.show(
+ NotificationType.error,
+ 'Daemon action failed',
+ resp.body.toString()
+ );
+ }
+ });
+ }
+
+ actionDisabled(actionType: string) {
+ if (this.selection?.hasSelection) {
+ const daemon = this.selection.selected[0];
+ if (daemon.daemon_type === 'mon' || daemon.daemon_type === 'mgr') {
+ return true; // don't allow actions on mon and mgr, dashboard requires them.
+ }
+ switch (actionType) {
+ case 'start':
+ if (daemon.status_desc === 'running') {
+ return true;
+ }
+ break;
+ case 'stop':
+ if (daemon.status_desc === 'stopped') {
+ return true;
+ }
+ break;
+ }
+ return false;
+ }
+ return true; // if no selection then disable everything
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html
new file mode 100644
index 000000000..704f0f98e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html
@@ -0,0 +1,4 @@
+<ng-container *ngIf="selection">
+ <cd-service-daemon-list [serviceName]="selection['service_name']">
+ </cd-service-daemon-list>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts
new file mode 100644
index 000000000..109ef039f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.spec.ts
@@ -0,0 +1,43 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
+
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ServiceDaemonListComponent } from '../service-daemon-list/service-daemon-list.component';
+import { ServiceDetailsComponent } from './service-details.component';
+
+describe('ServiceDetailsComponent', () => {
+ let component: ServiceDetailsComponent;
+ let fixture: ComponentFixture<ServiceDetailsComponent>;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ NgbNavModule,
+ NgxPipeFunctionModule,
+ ToastrModule.forRoot()
+ ],
+ declarations: [ServiceDetailsComponent, ServiceDaemonListComponent],
+ providers: [{ provide: SummaryService, useValue: { subscribeOnce: jest.fn() } }]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ServiceDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = new CdTableSelection();
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts
new file mode 100644
index 000000000..0aed38e67
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts
@@ -0,0 +1,17 @@
+import { Component, Input } from '@angular/core';
+
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permissions } from '~/app/shared/models/permissions';
+
+@Component({
+ selector: 'cd-service-details',
+ templateUrl: './service-details.component.html',
+ styleUrls: ['./service-details.component.scss']
+})
+export class ServiceDetailsComponent {
+ @Input()
+ permissions: Permissions;
+
+ @Input()
+ selection: CdTableSelection;
+}
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
new file mode 100644
index 000000000..dcd898d88
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html
@@ -0,0 +1,696 @@
+<cd-modal [pageURL]="pageURL"
+ [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="serviceForm"
+ novalidate>
+ <div class="modal-body">
+
+ <!-- Service type -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="service_type"
+ i18n>Type</label>
+ <div class="cd-col-form-input">
+ <select id="service_type"
+ name="service_type"
+ class="form-control"
+ formControlName="service_type"
+ (change)="getServiceIds($event.target.value)">
+ <option i18n
+ [ngValue]="null">-- Select a service type --</option>
+ <option *ngFor="let serviceType of serviceTypes"
+ [value]="serviceType">
+ {{ serviceType }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_type', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- backend_service -->
+ <div *ngIf="serviceForm.controls.service_type.value === 'ingress'"
+ class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['ingress'].includes(serviceForm.controls.service_type.value)}"
+ for="backend_service">Backend Service</label>
+ <div class="cd-col-form-input">
+ <select id="backend_service"
+ name="backend_service"
+ class="form-control"
+ formControlName="backend_service"
+ (change)="prePopulateId()">
+ <option *ngIf="services === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="services !== null && services.length === 0"
+ [ngValue]="null"
+ i18n>-- No service available --</option>
+ <option *ngIf="services !== null && services.length > 0"
+ [ngValue]="null"
+ i18n>-- Select an existing service --</option>
+ <option *ngFor="let service of services"
+ [value]="service.service_name">{{ service.service_name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('backend_service', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Service id -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.service_type.value !== 'snmp-gateway'">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['mds', 'rgw', 'nfs', 'iscsi', 'ingress'].includes(serviceForm.controls.service_type.value)}"
+ for="service_id">Id</label>
+ <div class="cd-col-form-input">
+ <input id="service_id"
+ class="form-control"
+ type="text"
+ formControlName="service_id">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_id', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_id', frm, 'uniqueName')"
+ i18n>This service id is already in use.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('service_id', frm, 'rgwPattern')"
+ i18n>The value does not match the pattern <strong>&lt;service_id&gt;[.&lt;realm_name&gt;.&lt;zone_name&gt;]</strong>.</span>
+ </div>
+ </div>
+
+ <!-- unmanaged -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="unmanaged"
+ type="checkbox"
+ formControlName="unmanaged">
+ <label class="custom-control-label"
+ for="unmanaged"
+ i18n>Unmanaged</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Placement -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="placement"
+ i18n>Placement</label>
+ <div class="cd-col-form-input">
+ <select id="placement"
+ class="form-control"
+ formControlName="placement">
+ <option i18n
+ value="hosts">Hosts</option>
+ <option i18n
+ value="label">Label</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Label -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.placement.value === 'label'"
+ class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="label">Label</label>
+ <div class="cd-col-form-input">
+ <input id="label"
+ class="form-control"
+ type="text"
+ formControlName="label"
+ [ngbTypeahead]="searchLabels"
+ (focus)="labelFocus.next($any($event).target.value)"
+ (click)="labelClick.next($any($event).target.value)">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('label', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Hosts -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.placement.value === 'hosts'"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="hosts"
+ i18n>Hosts</label>
+ <div class="cd-col-form-input">
+ <cd-select-badges id="hosts"
+ [data]="serviceForm.controls.hosts.value"
+ [options]="hosts.options"
+ [messages]="hosts.messages">
+ </cd-select-badges>
+ </div>
+ </div>
+
+ <!-- count -->
+ <div *ngIf="!serviceForm.controls.unmanaged.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="count">
+ <span i18n>Count</span>
+ <cd-helper i18n>Only that number of daemons will be created.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="count"
+ class="form-control"
+ type="number"
+ formControlName="count"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('count', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('count', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ </div>
+ </div>
+
+ <!-- RGW -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'rgw'">
+ <!-- rgw_frontend_port -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="rgw_frontend_port">Port</label>
+ <div class="cd-col-form-input">
+ <input id="rgw_frontend_port"
+ class="form-control"
+ type="number"
+ formControlName="rgw_frontend_port"
+ min="1"
+ max="65535">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('rgw_frontend_port', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('rgw_frontend_port', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('rgw_frontend_port', frm, 'max')"
+ i18n>The value cannot exceed 65535.</span>
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- iSCSI -->
+ <!-- pool -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.service_type.value === 'iscsi'">
+ <label i18n
+ class="cd-col-form-label required"
+ for="pool">Pool</label>
+ <div class="cd-col-form-input">
+ <select id="pool"
+ name="pool"
+ class="form-control"
+ formControlName="pool">
+ <option *ngIf="pools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="pools && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No pools available --</option>
+ <option *ngIf="pools && pools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a pool --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('pool', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- fields in iSCSI which are hidden when unmanaged is true -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && serviceForm.controls.service_type.value === 'iscsi'">
+ <!-- trusted_ip_list -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="trusted_ip_list">
+ <span i18n>Trusted IPs</span>
+ <cd-helper>
+ <span i18n>Comma separated list of IP addresses.</span>
+ <br>
+ <span i18n>Please add the <b>Ceph Manager</b> IP addresses here, otherwise the iSCSI gateways can't be reached.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="trusted_ip_list"
+ class="form-control"
+ type="text"
+ formControlName="trusted_ip_list">
+ </div>
+ </div>
+
+ <!-- api_port -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="api_port">Port</label>
+ <div class="cd-col-form-input">
+ <input id="api_port"
+ class="form-control"
+ type="number"
+ formControlName="api_port"
+ min="1"
+ max="65535">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_port', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_port', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_port', frm, 'max')"
+ i18n>The value cannot exceed 65535.</span>
+ </div>
+ </div>
+
+ <!-- api_user -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['iscsi'].includes(serviceForm.controls.service_type.value)}"
+ for="api_user">User</label>
+ <div class="cd-col-form-input">
+ <input id="api_user"
+ class="form-control"
+ type="text"
+ formControlName="api_user">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_user', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- api_password -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ [ngClass]="{'required': ['iscsi'].includes(serviceForm.controls.service_type.value)}"
+ for="api_password">Password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="api_password"
+ class="form-control"
+ type="password"
+ autocomplete="new-password"
+ formControlName="api_password">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="api_password">
+ </button>
+ <cd-copy-2-clipboard-button source="api_password">
+ </cd-copy-2-clipboard-button>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('api_password', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- Ingress -->
+ <ng-container *ngIf="serviceForm.controls.service_type.value === 'ingress'">
+ <!-- virtual_ip -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': ['ingress'].includes(serviceForm.controls.service_type.value)}"
+ for="virtual_ip">
+ <span i18n>Virtual IP</span>
+ <cd-helper>
+ <span i18n>The virtual IP address and subnet (in CIDR notation) where the ingress service will be available.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="virtual_ip"
+ class="form-control"
+ type="text"
+ formControlName="virtual_ip">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('virtual_ip', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- frontend_port -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': ['ingress'].includes(serviceForm.controls.service_type.value)}"
+ for="frontend_port">
+ <span i18n>Frontend Port</span>
+ <cd-helper>
+ <span i18n>The port used to access the ingress service.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="frontend_port"
+ class="form-control"
+ type="number"
+ formControlName="frontend_port"
+ min="1"
+ max="65535">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('frontend_port', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('frontend_port', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('frontend_port', frm, 'max')"
+ i18n>The value cannot exceed 65535.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('frontend_port', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- monitor_port -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': ['ingress'].includes(serviceForm.controls.service_type.value)}"
+ for="monitor_port">
+ <span i18n>Monitor Port</span>
+ <cd-helper>
+ <span i18n>The port used by haproxy for load balancer status.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="monitor_port"
+ class="form-control"
+ type="number"
+ formControlName="monitor_port"
+ min="1"
+ max="65535">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('monitor_port', frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('monitor_port', frm, 'min')"
+ i18n>The value must be at least 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('monitor_port', frm, 'max')"
+ i18n>The value cannot exceed 65535.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('monitor_port', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- virtual_interface_networks -->
+ <div class="form-group row"
+ *ngIf="!serviceForm.controls.unmanaged.value">
+ <label class="cd-col-form-label"
+ for="virtual_interface_networks">
+ <span i18n>CIDR Networks</span>
+ <cd-helper>
+ <span i18n>A list of networks to identify which network interface to use for the virtual IP address.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="virtual_interface_networks"
+ class="form-control"
+ type="text"
+ formControlName="virtual_interface_networks">
+ </div>
+ </div>
+ </ng-container>
+
+ <!-- SNMP-Gateway -->
+ <ng-container *ngIf="serviceForm.controls.service_type.value === 'snmp-gateway'">
+ <!-- snmp-version -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="snmp_version"
+ i18n>Version</label>
+ <div class="cd-col-form-input">
+ <select id="snmp_version"
+ name="snmp_version"
+ class="form-control"
+ formControlName="snmp_version"
+ (change)="clearValidations()">
+ <option i18n
+ [ngValue]="null">-- Select SNMP version --</option>
+ <option *ngFor="let snmpVersion of ['V2c', 'V3']"
+ [value]="snmpVersion">{{ snmpVersion }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_version', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- Destination -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="snmp_destination">
+ <span i18n>Destination</span>
+ <cd-helper>
+ <span i18n>Must be of the format hostname:port.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="snmp_destination"
+ class="form-control"
+ type="text"
+ formControlName="snmp_destination">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_destination', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_destination', frm, 'snmpDestinationPattern')"
+ i18n>The value does not match the pattern: <strong>hostname:port</strong></span>
+ </div>
+ </div>
+ <!-- Engine id for snmp V3 -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V3'">
+ <label class="cd-col-form-label required"
+ for="engine_id">
+ <span i18n>Engine Id</span>
+ <cd-helper>
+ <span i18n>Unique identifier for the device (in hex).</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="engine_id"
+ class="form-control"
+ type="text"
+ formControlName="engine_id">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('engine_id', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('engine_id', frm, 'snmpEngineIdPattern')"
+ i18n>The value does not match the pattern: <strong>Must be in hexadecimal and length must be multiple of 2 with min value = 10 amd max value = 64.</strong></span>
+ </div>
+ </div>
+ <!-- Auth protocol for snmp V3 -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V3'">
+ <label class="cd-col-form-label required"
+ for="auth_protocol"
+ i18n>Auth Protocol</label>
+ <div class="cd-col-form-input">
+ <select id="auth_protocol"
+ name="auth_protocol"
+ class="form-control"
+ formControlName="auth_protocol">
+ <option i18n
+ [ngValue]="null">-- Select auth protocol --</option>
+ <option *ngFor="let authProtocol of ['SHA', 'MD5']"
+ [value]="authProtocol">
+ {{ authProtocol }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('auth_protocol', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- Privacy protocol for snmp V3 -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V3'">
+ <label class="cd-col-form-label"
+ for="privacy_protocol"
+ i18n>Privacy Protocol</label>
+ <div class="cd-col-form-input">
+ <select id="privacy_protocol"
+ name="privacy_protocol"
+ class="form-control"
+ formControlName="privacy_protocol">
+ <option i18n
+ [ngValue]="null">-- Select privacy protocol --</option>
+ <option *ngFor="let privacyProtocol of ['DES', 'AES']"
+ [value]="privacyProtocol">
+ {{ privacyProtocol }}
+ </option>
+ </select>
+ </div>
+ </div>
+ <!-- Credentials -->
+ <fieldset>
+ <legend i18n>Credentials</legend>
+ <!-- snmp v2c snmp_community -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V2c'">
+ <label class="cd-col-form-label required"
+ for="snmp_community">
+ <span i18n>SNMP Community</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="snmp_community"
+ class="form-control"
+ type="text"
+ formControlName="snmp_community">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_community', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- snmp v3 auth username -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V3'">
+ <label class="cd-col-form-label required"
+ for="snmp_v3_auth_username">
+ <span i18n>Username</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="snmp_v3_auth_username"
+ class="form-control"
+ type="text"
+ formControlName="snmp_v3_auth_username">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_v3_auth_username', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- snmp v3 auth password -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V3'">
+ <label class="cd-col-form-label required"
+ for="snmp_v3_auth_password">
+ <span i18n>Password</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="snmp_v3_auth_password"
+ class="form-control"
+ type="password"
+ formControlName="snmp_v3_auth_password">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_v3_auth_password', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- snmp v3 priv password -->
+ <div class="form-group row"
+ *ngIf="serviceForm.controls.snmp_version.value === 'V3' && serviceForm.controls.privacy_protocol.value !== null && serviceForm.controls.privacy_protocol.value !== undefined">
+ <label class="cd-col-form-label required"
+ for="snmp_v3_priv_password">
+ <span i18n>Encryption</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="snmp_v3_priv_password"
+ class="form-control"
+ type="password"
+ formControlName="snmp_v3_priv_password">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('snmp_v3_priv_password', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </fieldset>
+ </ng-container>
+ <!-- RGW, Ingress & iSCSI -->
+ <ng-container *ngIf="!serviceForm.controls.unmanaged.value && ['rgw', 'iscsi', 'ingress'].includes(serviceForm.controls.service_type.value)">
+ <!-- ssl -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="ssl"
+ type="checkbox"
+ formControlName="ssl">
+ <label class="custom-control-label"
+ for="ssl"
+ i18n>SSL</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- ssl_cert -->
+ <div *ngIf="serviceForm.controls.ssl.value"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="ssl_cert">
+ <span i18n>Certificate</span>
+ <cd-helper i18n>The SSL certificate in PEM format.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <textarea id="ssl_cert"
+ class="form-control resize-vertical text-monospace text-pre"
+ formControlName="ssl_cert"
+ rows="5">
+ </textarea>
+ <input type="file"
+ (change)="fileUpload($event.target.files, 'ssl_cert')">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_cert', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_cert', frm, 'pattern')"
+ i18n>Invalid SSL certificate.</span>
+ </div>
+ </div>
+
+ <!-- ssl_key -->
+ <div *ngIf="serviceForm.controls.ssl.value && !(['rgw', 'ingress'].includes(serviceForm.controls.service_type.value))"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="ssl_key">
+ <span i18n>Private key</span>
+ <cd-helper i18n>The SSL private key in PEM format.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <textarea id="ssl_key"
+ class="form-control resize-vertical text-monospace text-pre"
+ formControlName="ssl_key"
+ rows="5">
+ </textarea>
+ <input type="file"
+ (change)="fileUpload($event.target.files,'ssl_key')">
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_key', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="serviceForm.showError('ssl_key', frm, 'pattern')"
+ i18n>Invalid SSL private key.</span>
+ </div>
+ </div>
+ </ng-container>
+ </div>
+
+ <div class="modal-footer">
+ <div class="text-right">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="serviceForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.scss
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
new file mode 100644
index 000000000..082fe9162
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts
@@ -0,0 +1,550 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { ServiceFormComponent } from './service-form.component';
+
+describe('ServiceFormComponent', () => {
+ let component: ServiceFormComponent;
+ let fixture: ComponentFixture<ServiceFormComponent>;
+ let cephServiceService: CephServiceService;
+ let form: CdFormGroup;
+ let formHelper: FormHelper;
+
+ configureTestBed({
+ declarations: [ServiceFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ServiceFormComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ form = component.serviceForm;
+ formHelper = new FormHelper(form);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should test form', () => {
+ beforeEach(() => {
+ cephServiceService = TestBed.inject(CephServiceService);
+ spyOn(cephServiceService, 'create').and.stub();
+ });
+
+ it('should test placement (host)', () => {
+ formHelper.setValue('service_type', 'crash');
+ formHelper.setValue('placement', 'hosts');
+ formHelper.setValue('hosts', ['mgr0', 'mon0', 'osd0']);
+ formHelper.setValue('count', 2);
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'crash',
+ placement: {
+ hosts: ['mgr0', 'mon0', 'osd0'],
+ count: 2
+ },
+ unmanaged: false
+ });
+ });
+
+ it('should test placement (label)', () => {
+ formHelper.setValue('service_type', 'mgr');
+ formHelper.setValue('placement', 'label');
+ formHelper.setValue('label', 'foo');
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'mgr',
+ placement: {
+ label: 'foo'
+ },
+ unmanaged: false
+ });
+ });
+
+ it('should submit valid count', () => {
+ formHelper.setValue('count', 1);
+ component.onSubmit();
+ formHelper.expectValid('count');
+ });
+
+ it('should submit invalid count (1)', () => {
+ formHelper.setValue('count', 0);
+ component.onSubmit();
+ formHelper.expectError('count', 'min');
+ });
+
+ it('should submit invalid count (2)', () => {
+ formHelper.setValue('count', 'abc');
+ component.onSubmit();
+ formHelper.expectError('count', 'pattern');
+ });
+
+ it('should test unmanaged', () => {
+ formHelper.setValue('service_type', 'mgr');
+ formHelper.setValue('service_id', 'svc');
+ formHelper.setValue('placement', 'label');
+ formHelper.setValue('label', 'bar');
+ formHelper.setValue('unmanaged', true);
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'mgr',
+ service_id: 'svc',
+ placement: {},
+ unmanaged: true
+ });
+ });
+
+ it('should test various services', () => {
+ _.forEach(
+ [
+ 'alertmanager',
+ 'crash',
+ 'grafana',
+ 'mds',
+ 'mgr',
+ 'mon',
+ 'node-exporter',
+ 'prometheus',
+ 'rbd-mirror'
+ ],
+ (serviceType) => {
+ formHelper.setValue('service_type', serviceType);
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: serviceType,
+ placement: {},
+ unmanaged: false
+ });
+ }
+ );
+ });
+
+ describe('should test service nfs', () => {
+ beforeEach(() => {
+ formHelper.setValue('service_type', 'nfs');
+ });
+
+ it('should submit nfs', () => {
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'nfs',
+ placement: {},
+ unmanaged: false
+ });
+ });
+ });
+
+ describe('should test service rgw', () => {
+ beforeEach(() => {
+ formHelper.setValue('service_type', 'rgw');
+ formHelper.setValue('service_id', 'svc');
+ });
+
+ it('should test rgw valid service id', () => {
+ formHelper.setValue('service_id', 'svc.realm.zone');
+ formHelper.expectValid('service_id');
+ formHelper.setValue('service_id', 'svc');
+ formHelper.expectValid('service_id');
+ });
+
+ it('should test rgw invalid service id', () => {
+ formHelper.setValue('service_id', '.');
+ formHelper.expectError('service_id', 'rgwPattern');
+ formHelper.setValue('service_id', 'svc.');
+ formHelper.expectError('service_id', 'rgwPattern');
+ formHelper.setValue('service_id', 'svc.realm');
+ formHelper.expectError('service_id', 'rgwPattern');
+ formHelper.setValue('service_id', 'svc.realm.');
+ formHelper.expectError('service_id', 'rgwPattern');
+ formHelper.setValue('service_id', '.svc.realm');
+ formHelper.expectError('service_id', 'rgwPattern');
+ formHelper.setValue('service_id', 'svc.realm.zone.');
+ formHelper.expectError('service_id', 'rgwPattern');
+ });
+
+ it('should submit rgw with realm and zone', () => {
+ formHelper.setValue('service_id', 'svc.my-realm.my-zone');
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'rgw',
+ service_id: 'svc',
+ rgw_realm: 'my-realm',
+ rgw_zone: 'my-zone',
+ placement: {},
+ unmanaged: false,
+ ssl: false
+ });
+ });
+
+ it('should submit rgw with port and ssl enabled', () => {
+ formHelper.setValue('rgw_frontend_port', 1234);
+ formHelper.setValue('ssl', true);
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'rgw',
+ service_id: 'svc',
+ placement: {},
+ unmanaged: false,
+ rgw_frontend_port: 1234,
+ rgw_frontend_ssl_certificate: '',
+ ssl: true
+ });
+ });
+
+ it('should submit valid rgw port (1)', () => {
+ formHelper.setValue('rgw_frontend_port', 1);
+ component.onSubmit();
+ formHelper.expectValid('rgw_frontend_port');
+ });
+
+ it('should submit valid rgw port (2)', () => {
+ formHelper.setValue('rgw_frontend_port', 65535);
+ component.onSubmit();
+ formHelper.expectValid('rgw_frontend_port');
+ });
+
+ it('should submit invalid rgw port (1)', () => {
+ formHelper.setValue('rgw_frontend_port', 0);
+ fixture.detectChanges();
+ formHelper.expectError('rgw_frontend_port', 'min');
+ });
+
+ it('should submit invalid rgw port (2)', () => {
+ formHelper.setValue('rgw_frontend_port', 65536);
+ fixture.detectChanges();
+ formHelper.expectError('rgw_frontend_port', 'max');
+ });
+
+ it('should submit invalid rgw port (3)', () => {
+ formHelper.setValue('rgw_frontend_port', 'abc');
+ component.onSubmit();
+ formHelper.expectError('rgw_frontend_port', 'pattern');
+ });
+
+ it('should submit rgw w/o port', () => {
+ formHelper.setValue('ssl', false);
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'rgw',
+ service_id: 'svc',
+ placement: {},
+ unmanaged: false,
+ ssl: false
+ });
+ });
+
+ it('should not show private key field', () => {
+ formHelper.setValue('ssl', true);
+ fixture.detectChanges();
+ const ssl_key = fixture.debugElement.query(By.css('#ssl_key'));
+ expect(ssl_key).toBeNull();
+ });
+
+ it('should test .pem file', () => {
+ const pemCert = `
+-----BEGIN CERTIFICATE-----
+iJ5IbgzlKPssdYwuAEI3yPZxX/g5vKBrgcyD3LttLL/DlElq/1xCnwVrv7WROSNu
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+mn/S7BNBEC7AGe5ajmN+8hBTGdACUXe8rwMNrtTy/MwBZ0VpJsAAjJh+aptZh5yB
+-----END CERTIFICATE-----
+-----BEGIN RSA PRIVATE KEY-----
+x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA==
+-----END RSA PRIVATE KEY-----`;
+ formHelper.setValue('ssl', true);
+ formHelper.setValue('ssl_cert', pemCert);
+ fixture.detectChanges();
+ formHelper.expectValid('ssl_cert');
+ });
+ });
+
+ describe('should test service iscsi', () => {
+ beforeEach(() => {
+ formHelper.setValue('service_type', 'iscsi');
+ formHelper.setValue('pool', 'xyz');
+ formHelper.setValue('api_user', 'user');
+ formHelper.setValue('api_password', 'password');
+ formHelper.setValue('ssl', false);
+ });
+
+ it('should submit iscsi', () => {
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'iscsi',
+ placement: {},
+ unmanaged: false,
+ pool: 'xyz',
+ api_user: 'user',
+ api_password: 'password',
+ api_secure: false
+ });
+ });
+
+ it('should submit iscsi with trusted ips', () => {
+ formHelper.setValue('ssl', true);
+ formHelper.setValue('trusted_ip_list', ' 172.16.0.5, 192.1.1.10 ');
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'iscsi',
+ placement: {},
+ unmanaged: false,
+ pool: 'xyz',
+ api_user: 'user',
+ api_password: 'password',
+ api_secure: true,
+ ssl_cert: '',
+ ssl_key: '',
+ trusted_ip_list: '172.16.0.5, 192.1.1.10'
+ });
+ });
+
+ it('should submit iscsi with port', () => {
+ formHelper.setValue('api_port', 456);
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'iscsi',
+ placement: {},
+ unmanaged: false,
+ pool: 'xyz',
+ api_user: 'user',
+ api_password: 'password',
+ api_secure: false,
+ api_port: 456
+ });
+ });
+
+ it('should submit valid iscsi port (1)', () => {
+ formHelper.setValue('api_port', 1);
+ component.onSubmit();
+ formHelper.expectValid('api_port');
+ });
+
+ it('should submit valid iscsi port (2)', () => {
+ formHelper.setValue('api_port', 65535);
+ component.onSubmit();
+ formHelper.expectValid('api_port');
+ });
+
+ it('should submit invalid iscsi port (1)', () => {
+ formHelper.setValue('api_port', 0);
+ fixture.detectChanges();
+ formHelper.expectError('api_port', 'min');
+ });
+
+ it('should submit invalid iscsi port (2)', () => {
+ formHelper.setValue('api_port', 65536);
+ fixture.detectChanges();
+ formHelper.expectError('api_port', 'max');
+ });
+
+ it('should submit invalid iscsi port (3)', () => {
+ formHelper.setValue('api_port', 'abc');
+ component.onSubmit();
+ formHelper.expectError('api_port', 'pattern');
+ });
+
+ it('should throw error when there is no pool', () => {
+ formHelper.expectErrorChange('pool', '', 'required');
+ });
+ });
+
+ describe('should test service ingress', () => {
+ beforeEach(() => {
+ formHelper.setValue('service_type', 'ingress');
+ formHelper.setValue('backend_service', 'rgw.foo');
+ formHelper.setValue('virtual_ip', '192.168.20.1/24');
+ formHelper.setValue('ssl', false);
+ });
+
+ it('should submit ingress', () => {
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'ingress',
+ placement: {},
+ unmanaged: false,
+ backend_service: 'rgw.foo',
+ service_id: 'rgw.foo',
+ virtual_ip: '192.168.20.1/24',
+ virtual_interface_networks: null,
+ ssl: false
+ });
+ });
+
+ it('should pre-populate the service id', () => {
+ component.prePopulateId();
+ const prePopulatedID = component.serviceForm.getValue('service_id');
+ expect(prePopulatedID).toBe('rgw.foo');
+ });
+
+ it('should submit valid frontend and monitor port', () => {
+ // min value
+ formHelper.setValue('frontend_port', 1);
+ formHelper.setValue('monitor_port', 1);
+ fixture.detectChanges();
+ formHelper.expectValid('frontend_port');
+ formHelper.expectValid('monitor_port');
+
+ // max value
+ formHelper.setValue('frontend_port', 65535);
+ formHelper.setValue('monitor_port', 65535);
+ fixture.detectChanges();
+ formHelper.expectValid('frontend_port');
+ formHelper.expectValid('monitor_port');
+ });
+
+ it('should submit invalid frontend and monitor port', () => {
+ // min
+ formHelper.setValue('frontend_port', 0);
+ formHelper.setValue('monitor_port', 0);
+ fixture.detectChanges();
+ formHelper.expectError('frontend_port', 'min');
+ formHelper.expectError('monitor_port', 'min');
+
+ // max
+ formHelper.setValue('frontend_port', 65536);
+ formHelper.setValue('monitor_port', 65536);
+ fixture.detectChanges();
+ formHelper.expectError('frontend_port', 'max');
+ formHelper.expectError('monitor_port', 'max');
+
+ // pattern
+ formHelper.setValue('frontend_port', 'abc');
+ formHelper.setValue('monitor_port', 'abc');
+ component.onSubmit();
+ formHelper.expectError('frontend_port', 'pattern');
+ formHelper.expectError('monitor_port', 'pattern');
+ });
+
+ it('should not show private key field with ssl enabled', () => {
+ formHelper.setValue('ssl', true);
+ fixture.detectChanges();
+ const ssl_key = fixture.debugElement.query(By.css('#ssl_key'));
+ expect(ssl_key).toBeNull();
+ });
+
+ it('should test .pem file with ssl enabled', () => {
+ const pemCert = `
+-----BEGIN CERTIFICATE-----
+iJ5IbgzlKPssdYwuAEI3yPZxX/g5vKBrgcyD3LttLL/DlElq/1xCnwVrv7WROSNu
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+mn/S7BNBEC7AGe5ajmN+8hBTGdACUXe8rwMNrtTy/MwBZ0VpJsAAjJh+aptZh5yB
+-----END CERTIFICATE-----
+-----BEGIN RSA PRIVATE KEY-----
+x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA==
+-----END RSA PRIVATE KEY-----`;
+ formHelper.setValue('ssl', true);
+ formHelper.setValue('ssl_cert', pemCert);
+ fixture.detectChanges();
+ formHelper.expectValid('ssl_cert');
+ });
+ });
+
+ describe('should test service snmp-gateway', () => {
+ beforeEach(() => {
+ formHelper.setValue('service_type', 'snmp-gateway');
+ formHelper.setValue('snmp_destination', '192.168.20.1:8443');
+ });
+
+ it('should test snmp-gateway service with V2c', () => {
+ formHelper.setValue('snmp_version', 'V2c');
+ formHelper.setValue('snmp_community', 'public');
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'snmp-gateway',
+ placement: {},
+ unmanaged: false,
+ snmp_version: 'V2c',
+ snmp_destination: '192.168.20.1:8443',
+ credentials: {
+ snmp_community: 'public'
+ }
+ });
+ });
+
+ it('should test snmp-gateway service with V3', () => {
+ formHelper.setValue('snmp_version', 'V3');
+ formHelper.setValue('engine_id', '800C53F00000');
+ formHelper.setValue('auth_protocol', 'SHA');
+ formHelper.setValue('privacy_protocol', 'DES');
+ formHelper.setValue('snmp_v3_auth_username', 'testuser');
+ formHelper.setValue('snmp_v3_auth_password', 'testpass');
+ formHelper.setValue('snmp_v3_priv_password', 'testencrypt');
+ component.onSubmit();
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'snmp-gateway',
+ placement: {},
+ unmanaged: false,
+ snmp_version: 'V3',
+ snmp_destination: '192.168.20.1:8443',
+ engine_id: '800C53F00000',
+ auth_protocol: 'SHA',
+ privacy_protocol: 'DES',
+ credentials: {
+ snmp_v3_auth_username: 'testuser',
+ snmp_v3_auth_password: 'testpass',
+ snmp_v3_priv_password: 'testencrypt'
+ }
+ });
+ });
+
+ it('should submit invalid snmp destination', () => {
+ formHelper.setValue('snmp_version', 'V2c');
+ formHelper.setValue('snmp_destination', '192.168.20.1');
+ formHelper.setValue('snmp_community', 'public');
+ formHelper.expectError('snmp_destination', 'snmpDestinationPattern');
+ });
+
+ it('should submit invalid snmp engine id', () => {
+ formHelper.setValue('snmp_version', 'V3');
+ formHelper.setValue('snmp_destination', '192.168.20.1');
+ formHelper.setValue('engine_id', 'AABBCCDDE');
+ formHelper.setValue('auth_protocol', 'SHA');
+ formHelper.setValue('privacy_protocol', 'DES');
+ formHelper.setValue('snmp_v3_auth_username', 'testuser');
+ formHelper.setValue('snmp_v3_auth_password', 'testpass');
+
+ formHelper.expectError('engine_id', 'snmpEngineIdPattern');
+ });
+ });
+
+ describe('check edit fields', () => {
+ beforeEach(() => {
+ component.editing = true;
+ });
+
+ it('should check whether edit field is correctly loaded', () => {
+ const cephServiceSpy = spyOn(cephServiceService, 'list').and.callThrough();
+ 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;
+ expect(serviceType.disabled).toBeTruthy();
+ expect(serviceId.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
new file mode 100644
index 000000000..4cab78437
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts
@@ -0,0 +1,678 @@
+import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { AbstractControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { merge, Observable, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators';
+
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-service-form',
+ templateUrl: './service-form.component.html',
+ styleUrls: ['./service-form.component.scss']
+})
+export class ServiceFormComponent extends CdForm implements OnInit {
+ readonly RGW_SVC_ID_PATTERN = /^([^.]+)(\.([^.]+)\.([^.]+))?$/;
+ readonly SNMP_DESTINATION_PATTERN = /^[^\:]+:[0-9]/;
+ readonly SNMP_ENGINE_ID_PATTERN = /^[0-9A-Fa-f]{10,64}/g;
+ readonly INGRESS_SUPPORTED_SERVICE_TYPES = ['rgw', 'nfs'];
+ @ViewChild(NgbTypeahead, { static: false })
+ typeahead: NgbTypeahead;
+
+ @Input() hiddenServices: string[] = [];
+
+ @Input() editing = false;
+
+ @Input() serviceName: string;
+
+ @Input() serviceType: string;
+
+ serviceForm: CdFormGroup;
+ action: string;
+ resource: string;
+ serviceTypes: string[] = [];
+ serviceIds: string[] = [];
+ hosts: any;
+ labels: string[];
+ labelClick = new Subject<string>();
+ labelFocus = new Subject<string>();
+ pools: Array<object>;
+ services: Array<CephServiceSpec> = [];
+ pageURL: string;
+ serviceList: CephServiceSpec[];
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private cephServiceService: CephServiceService,
+ private formBuilder: CdFormBuilder,
+ private hostService: HostService,
+ private poolService: PoolService,
+ private router: Router,
+ private taskWrapperService: TaskWrapperService,
+ private route: ActivatedRoute,
+ public activeModal: NgbActiveModal
+ ) {
+ super();
+ this.resource = $localize`service`;
+ this.hosts = {
+ options: [],
+ messages: new SelectMessages({
+ empty: $localize`There are no hosts.`,
+ filter: $localize`Filter hosts`
+ })
+ };
+ this.createForm();
+ }
+
+ createForm() {
+ this.serviceForm = this.formBuilder.group({
+ // Global
+ service_type: [null, [Validators.required]],
+ service_id: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'mds'
+ }),
+ CdValidators.requiredIf({
+ service_type: 'nfs'
+ }),
+ CdValidators.requiredIf({
+ service_type: 'iscsi'
+ }),
+ CdValidators.requiredIf({
+ service_type: 'ingress'
+ }),
+ CdValidators.composeIf(
+ {
+ service_type: 'rgw'
+ },
+ [
+ Validators.required,
+ CdValidators.custom('rgwPattern', (value: string) => {
+ if (_.isEmpty(value)) {
+ return false;
+ }
+ return !this.RGW_SVC_ID_PATTERN.test(value);
+ })
+ ]
+ ),
+ CdValidators.custom('uniqueName', (service_id: string) => {
+ return this.serviceIds && this.serviceIds.includes(service_id);
+ })
+ ]
+ ],
+ placement: ['hosts'],
+ label: [
+ null,
+ [
+ CdValidators.requiredIf({
+ placement: 'label',
+ unmanaged: false
+ })
+ ]
+ ],
+ hosts: [[]],
+ count: [null, [CdValidators.number(false)]],
+ unmanaged: [false],
+ // iSCSI
+ pool: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'iscsi'
+ })
+ ]
+ ],
+ // RGW
+ rgw_frontend_port: [null, [CdValidators.number(false)]],
+ // iSCSI
+ trusted_ip_list: [null],
+ api_port: [null, [CdValidators.number(false)]],
+ api_user: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'iscsi',
+ unmanaged: false
+ })
+ ]
+ ],
+ api_password: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'iscsi',
+ unmanaged: false
+ })
+ ]
+ ],
+ // Ingress
+ backend_service: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'ingress'
+ })
+ ]
+ ],
+ virtual_ip: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'ingress'
+ })
+ ]
+ ],
+ frontend_port: [
+ null,
+ [
+ CdValidators.number(false),
+ CdValidators.requiredIf({
+ service_type: 'ingress'
+ })
+ ]
+ ],
+ monitor_port: [
+ null,
+ [
+ CdValidators.number(false),
+ CdValidators.requiredIf({
+ service_type: 'ingress'
+ })
+ ]
+ ],
+ virtual_interface_networks: [null],
+ // RGW, Ingress & iSCSI
+ ssl: [false],
+ ssl_cert: [
+ '',
+ [
+ CdValidators.composeIf(
+ {
+ service_type: 'rgw',
+ unmanaged: false,
+ ssl: true
+ },
+ [Validators.required, CdValidators.pemCert()]
+ ),
+ CdValidators.composeIf(
+ {
+ service_type: 'iscsi',
+ unmanaged: false,
+ ssl: true
+ },
+ [Validators.required, CdValidators.sslCert()]
+ ),
+ CdValidators.composeIf(
+ {
+ service_type: 'ingress',
+ unmanaged: false,
+ ssl: true
+ },
+ [Validators.required, CdValidators.pemCert()]
+ )
+ ]
+ ],
+ ssl_key: [
+ '',
+ [
+ CdValidators.composeIf(
+ {
+ service_type: 'iscsi',
+ unmanaged: false,
+ ssl: true
+ },
+ [Validators.required, CdValidators.sslPrivKey()]
+ )
+ ]
+ ],
+ // snmp-gateway
+ snmp_version: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'snmp-gateway'
+ })
+ ]
+ ],
+ snmp_destination: [
+ null,
+ {
+ validators: [
+ CdValidators.requiredIf({
+ service_type: 'snmp-gateway'
+ }),
+ CdValidators.custom('snmpDestinationPattern', (value: string) => {
+ if (_.isEmpty(value)) {
+ return false;
+ }
+ return !this.SNMP_DESTINATION_PATTERN.test(value);
+ })
+ ]
+ }
+ ],
+ engine_id: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'snmp-gateway'
+ }),
+ CdValidators.custom('snmpEngineIdPattern', (value: string) => {
+ if (_.isEmpty(value)) {
+ return false;
+ }
+ return !this.SNMP_ENGINE_ID_PATTERN.test(value);
+ })
+ ]
+ ],
+ auth_protocol: [
+ 'SHA',
+ [
+ CdValidators.requiredIf({
+ service_type: 'snmp-gateway'
+ })
+ ]
+ ],
+ privacy_protocol: [null],
+ snmp_community: [
+ null,
+ [
+ CdValidators.requiredIf({
+ snmp_version: 'V2c'
+ })
+ ]
+ ],
+ snmp_v3_auth_username: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'snmp-gateway'
+ })
+ ]
+ ],
+ snmp_v3_auth_password: [
+ null,
+ [
+ CdValidators.requiredIf({
+ service_type: 'snmp-gateway'
+ })
+ ]
+ ],
+ snmp_v3_priv_password: [
+ null,
+ [
+ CdValidators.requiredIf({
+ privacy_protocol: { op: '!empty' }
+ })
+ ]
+ ]
+ });
+ }
+
+ ngOnInit(): void {
+ this.action = this.actionLabels.CREATE;
+ if (this.router.url.includes('services/(modal:create')) {
+ this.pageURL = 'services';
+ } else if (this.router.url.includes('services/(modal:edit')) {
+ this.editing = true;
+ this.pageURL = 'services';
+ this.route.params.subscribe((params: { type: string; name: string }) => {
+ this.serviceName = params.name;
+ this.serviceType = params.type;
+ });
+ }
+
+ this.cephServiceService.list().subscribe((services: CephServiceSpec[]) => {
+ this.serviceList = services;
+ this.services = services.filter((service: any) =>
+ this.INGRESS_SUPPORTED_SERVICE_TYPES.includes(service.service_type)
+ );
+ });
+
+ this.cephServiceService.getKnownTypes().subscribe((resp: Array<string>) => {
+ // Remove service types:
+ // osd - This is deployed a different way.
+ // container - This should only be used in the CLI.
+ this.hiddenServices.push('osd', 'container');
+
+ this.serviceTypes = _.difference(resp, this.hiddenServices).sort();
+ });
+ this.hostService.list('false').subscribe((resp: object[]) => {
+ const options: SelectOption[] = [];
+ _.forEach(resp, (host: object) => {
+ 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.poolService.getList().subscribe((resp: Array<object>) => {
+ this.pools = resp;
+ });
+
+ if (this.editing) {
+ this.action = this.actionLabels.EDIT;
+ this.disableForEditing(this.serviceType);
+ this.cephServiceService.list(this.serviceName).subscribe((response: CephServiceSpec[]) => {
+ const formKeys = ['service_type', 'service_id', 'unmanaged'];
+ formKeys.forEach((keys) => {
+ this.serviceForm.get(keys).setValue(response[0][keys]);
+ });
+ if (!response[0]['unmanaged']) {
+ const placementKey = Object.keys(response[0]['placement'])[0];
+ let placementValue: string;
+ ['hosts', 'label'].indexOf(placementKey) >= 0
+ ? (placementValue = placementKey)
+ : (placementValue = 'hosts');
+ this.serviceForm.get('placement').setValue(placementValue);
+ this.serviceForm.get('count').setValue(response[0]['placement']['count']);
+ if (response[0]?.placement[placementValue]) {
+ this.serviceForm.get(placementValue).setValue(response[0]?.placement[placementValue]);
+ }
+ }
+ switch (this.serviceType) {
+ case 'iscsi':
+ const specKeys = ['pool', 'api_password', 'api_user', 'trusted_ip_list', 'api_port'];
+ specKeys.forEach((key) => {
+ this.serviceForm.get(key).setValue(response[0].spec[key]);
+ });
+ this.serviceForm.get('ssl').setValue(response[0].spec?.api_secure);
+ if (response[0].spec?.api_secure) {
+ this.serviceForm.get('ssl_cert').setValue(response[0].spec?.ssl_cert);
+ this.serviceForm.get('ssl_key').setValue(response[0].spec?.ssl_key);
+ }
+ break;
+ case 'rgw':
+ this.serviceForm.get('rgw_frontend_port').setValue(response[0].spec?.rgw_frontend_port);
+ this.serviceForm.get('ssl').setValue(response[0].spec?.ssl);
+ if (response[0].spec?.ssl) {
+ this.serviceForm
+ .get('ssl_cert')
+ .setValue(response[0].spec?.rgw_frontend_ssl_certificate);
+ }
+ break;
+ case 'ingress':
+ const ingressSpecKeys = [
+ 'backend_service',
+ 'virtual_ip',
+ 'frontend_port',
+ 'monitor_port',
+ 'virtual_interface_networks',
+ 'ssl'
+ ];
+ ingressSpecKeys.forEach((key) => {
+ this.serviceForm.get(key).setValue(response[0].spec[key]);
+ });
+ if (response[0].spec?.ssl) {
+ this.serviceForm.get('ssl_cert').setValue(response[0].spec?.ssl_cert);
+ this.serviceForm.get('ssl_key').setValue(response[0].spec?.ssl_key);
+ }
+ break;
+ case 'snmp-gateway':
+ const snmpCommonSpecKeys = ['snmp_version', 'snmp_destination'];
+ snmpCommonSpecKeys.forEach((key) => {
+ this.serviceForm.get(key).setValue(response[0].spec[key]);
+ });
+ if (this.serviceForm.getValue('snmp_version') === 'V3') {
+ const snmpV3SpecKeys = [
+ 'engine_id',
+ 'auth_protocol',
+ 'privacy_protocol',
+ 'snmp_v3_auth_username',
+ 'snmp_v3_auth_password',
+ 'snmp_v3_priv_password'
+ ];
+ snmpV3SpecKeys.forEach((key) => {
+ if (key !== null) {
+ if (
+ key === 'snmp_v3_auth_username' ||
+ key === 'snmp_v3_auth_password' ||
+ key === 'snmp_v3_priv_password'
+ ) {
+ this.serviceForm.get(key).setValue(response[0].spec['credentials'][key]);
+ } else {
+ this.serviceForm.get(key).setValue(response[0].spec[key]);
+ }
+ }
+ });
+ } else {
+ this.serviceForm
+ .get('snmp_community')
+ .setValue(response[0].spec['credentials']['snmp_community']);
+ }
+ break;
+ }
+ });
+ }
+ }
+
+ getServiceIds(selectedServiceType: string) {
+ this.serviceIds = this.serviceList
+ .filter((service) => service['service_type'] === selectedServiceType)
+ .map((service) => service['service_id']);
+ }
+
+ disableForEditing(serviceType: string) {
+ const disableForEditKeys = ['service_type', 'service_id'];
+ disableForEditKeys.forEach((key) => {
+ this.serviceForm.get(key).disable();
+ });
+ switch (serviceType) {
+ case 'ingress':
+ this.serviceForm.get('backend_service').disable();
+ }
+ }
+
+ searchLabels = (text$: Observable<string>) => {
+ 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(files: FileList, controlName: string) {
+ const file: File = files[0];
+ const reader = new FileReader();
+ reader.addEventListener('load', (event: ProgressEvent<FileReader>) => {
+ const control: AbstractControl = this.serviceForm.get(controlName);
+ control.setValue(event.target.result);
+ control.markAsDirty();
+ control.markAsTouched();
+ control.updateValueAndValidity();
+ });
+ reader.readAsText(file, 'utf8');
+ }
+
+ prePopulateId() {
+ const control: AbstractControl = this.serviceForm.get('service_id');
+ const backendService = this.serviceForm.getValue('backend_service');
+ // Set Id as read-only
+ control.reset({ value: backendService, disabled: true });
+ }
+
+ onSubmit() {
+ const self = this;
+ const values: object = this.serviceForm.getRawValue();
+ const serviceType: string = values['service_type'];
+ let taskUrl = `service/${URLVerbs.CREATE}`;
+ if (this.editing) {
+ taskUrl = `service/${URLVerbs.EDIT}`;
+ }
+ const serviceSpec: object = {
+ service_type: serviceType,
+ placement: {},
+ unmanaged: values['unmanaged']
+ };
+ let svcId: string;
+ if (serviceType === 'rgw') {
+ const svcIdMatch = values['service_id'].match(this.RGW_SVC_ID_PATTERN);
+ svcId = svcIdMatch[1];
+ if (svcIdMatch[3]) {
+ serviceSpec['rgw_realm'] = svcIdMatch[3];
+ serviceSpec['rgw_zone'] = svcIdMatch[4];
+ }
+ } else {
+ svcId = values['service_id'];
+ }
+ const serviceId: string = svcId;
+ let serviceName: string = serviceType;
+ if (_.isString(serviceId) && !_.isEmpty(serviceId)) {
+ serviceName = `${serviceType}.${serviceId}`;
+ serviceSpec['service_id'] = serviceId;
+ }
+
+ // These services has some fields to be
+ // filled out even if unmanaged is true
+ switch (serviceType) {
+ case 'ingress':
+ serviceSpec['backend_service'] = values['backend_service'];
+ serviceSpec['service_id'] = values['backend_service'];
+ if (_.isNumber(values['frontend_port']) && values['frontend_port'] > 0) {
+ serviceSpec['frontend_port'] = values['frontend_port'];
+ }
+ if (_.isString(values['virtual_ip']) && !_.isEmpty(values['virtual_ip'])) {
+ serviceSpec['virtual_ip'] = values['virtual_ip'].trim();
+ }
+ if (_.isNumber(values['monitor_port']) && values['monitor_port'] > 0) {
+ serviceSpec['monitor_port'] = values['monitor_port'];
+ }
+ break;
+
+ case 'iscsi':
+ serviceSpec['pool'] = values['pool'];
+ break;
+
+ case 'snmp-gateway':
+ serviceSpec['credentials'] = {};
+ serviceSpec['snmp_version'] = values['snmp_version'];
+ serviceSpec['snmp_destination'] = values['snmp_destination'];
+ if (values['snmp_version'] === 'V3') {
+ serviceSpec['engine_id'] = values['engine_id'];
+ serviceSpec['auth_protocol'] = values['auth_protocol'];
+ serviceSpec['credentials']['snmp_v3_auth_username'] = values['snmp_v3_auth_username'];
+ serviceSpec['credentials']['snmp_v3_auth_password'] = values['snmp_v3_auth_password'];
+ if (values['privacy_protocol'] !== null) {
+ serviceSpec['privacy_protocol'] = values['privacy_protocol'];
+ serviceSpec['credentials']['snmp_v3_priv_password'] = values['snmp_v3_priv_password'];
+ }
+ } else {
+ serviceSpec['credentials']['snmp_community'] = values['snmp_community'];
+ }
+ break;
+ }
+
+ if (!values['unmanaged']) {
+ switch (values['placement']) {
+ case 'hosts':
+ if (values['hosts'].length > 0) {
+ serviceSpec['placement']['hosts'] = values['hosts'];
+ }
+ break;
+ case 'label':
+ serviceSpec['placement']['label'] = values['label'];
+ break;
+ }
+ if (_.isNumber(values['count']) && values['count'] > 0) {
+ serviceSpec['placement']['count'] = values['count'];
+ }
+ switch (serviceType) {
+ case 'rgw':
+ if (_.isNumber(values['rgw_frontend_port']) && values['rgw_frontend_port'] > 0) {
+ serviceSpec['rgw_frontend_port'] = values['rgw_frontend_port'];
+ }
+ serviceSpec['ssl'] = values['ssl'];
+ if (values['ssl']) {
+ serviceSpec['rgw_frontend_ssl_certificate'] = values['ssl_cert']?.trim();
+ }
+ break;
+ case 'iscsi':
+ if (_.isString(values['trusted_ip_list']) && !_.isEmpty(values['trusted_ip_list'])) {
+ serviceSpec['trusted_ip_list'] = values['trusted_ip_list'].trim();
+ }
+ if (_.isNumber(values['api_port']) && values['api_port'] > 0) {
+ serviceSpec['api_port'] = values['api_port'];
+ }
+ serviceSpec['api_user'] = values['api_user'];
+ serviceSpec['api_password'] = values['api_password'];
+ serviceSpec['api_secure'] = values['ssl'];
+ if (values['ssl']) {
+ serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
+ serviceSpec['ssl_key'] = values['ssl_key']?.trim();
+ }
+ break;
+ case 'ingress':
+ serviceSpec['ssl'] = values['ssl'];
+ if (values['ssl']) {
+ serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
+ serviceSpec['ssl_key'] = values['ssl_key']?.trim();
+ }
+ serviceSpec['virtual_interface_networks'] = values['virtual_interface_networks'];
+ break;
+ }
+ }
+
+ this.taskWrapperService
+ .wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ service_name: serviceName
+ }),
+ call: this.editing
+ ? this.cephServiceService.update(serviceSpec)
+ : this.cephServiceService.create(serviceSpec)
+ })
+ .subscribe({
+ error() {
+ self.serviceForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.pageURL === 'services'
+ ? this.router.navigate([this.pageURL, { outlets: { modal: null } }])
+ : this.activeModal.close();
+ }
+ });
+ }
+
+ clearValidations() {
+ const snmpVersion = this.serviceForm.getValue('snmp_version');
+ const privacyProtocol = this.serviceForm.getValue('privacy_protocol');
+ if (snmpVersion === 'V3') {
+ this.serviceForm.get('snmp_community').clearValidators();
+ } else {
+ this.serviceForm.get('engine_id').clearValidators();
+ this.serviceForm.get('auth_protocol').clearValidators();
+ this.serviceForm.get('privacy_protocol').clearValidators();
+ this.serviceForm.get('snmp_v3_auth_username').clearValidators();
+ this.serviceForm.get('snmp_v3_auth_password').clearValidators();
+ }
+ if (privacyProtocol === null) {
+ this.serviceForm.get('snmp_v3_priv_password').clearValidators();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html
new file mode 100644
index 000000000..36ab431fc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html
@@ -0,0 +1,25 @@
+<cd-orchestrator-doc-panel *ngIf="showDocPanel"></cd-orchestrator-doc-panel>
+<ng-container *ngIf="orchStatus?.available">
+ <cd-table [data]="services"
+ [columns]="columns"
+ identifier="service_name"
+ forceIdentifier="true"
+ columnMode="flex"
+ selectionType="single"
+ [autoReload]="5000"
+ (fetchData)="getServices($event)"
+ [hasDetails]="hasDetails"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permissions.hosts"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-service-details cdTableDetail
+ [permissions]="permissions"
+ [selection]="expandedRow">
+ </cd-service-details>
+ </cd-table>
+</ng-container>
+<router-outlet name="modal"></router-outlet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts
new file mode 100644
index 000000000..69d28e705
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts
@@ -0,0 +1,97 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CoreModule } from '~/app/core/core.module';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ServicesComponent } from './services.component';
+
+describe('ServicesComponent', () => {
+ let component: ServicesComponent;
+ let fixture: ComponentFixture<ServicesComponent>;
+
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({ hosts: ['read'] });
+ }
+ };
+
+ const services = [
+ {
+ service_type: 'osd',
+ service_name: 'osd',
+ status: {
+ size: 3,
+ running: 3,
+ last_refresh: '2020-02-25T04:33:26.465699'
+ }
+ },
+ {
+ service_type: 'crash',
+ service_name: 'crash',
+ status: {
+ size: 1,
+ running: 1,
+ last_refresh: '2020-02-25T04:33:26.465766'
+ }
+ }
+ ];
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ CephModule,
+ CoreModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ServicesComponent);
+ component = fixture.componentInstance;
+ const orchService = TestBed.inject(OrchestratorService);
+ const cephServiceService = TestBed.inject(CephServiceService);
+ spyOn(orchService, 'status').and.returnValue(of({ available: true }));
+ spyOn(cephServiceService, 'list').and.returnValue(of(services));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have columns that are sortable', () => {
+ expect(
+ component.columns
+ // Filter the 'Expand/Collapse Row' column.
+ .filter((column) => !(column.cellClass === 'cd-datatable-expand-collapse'))
+ // Filter the 'Placement' column.
+ .filter((column) => !(column.prop === ''))
+ .every((column) => Boolean(column.prop))
+ ).toBeTruthy();
+ });
+
+ it('should return all services', () => {
+ component.getServices(new CdTableFetchDataContext(() => undefined));
+ expect(component.services.length).toBe(2);
+ });
+
+ it('should not display doc panel if orchestrator is available', () => {
+ expect(component.showDocPanel).toBeFalsy();
+ });
+});
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
new file mode 100644
index 000000000..318a54a6e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts
@@ -0,0 +1,259 @@
+import { Component, Input, OnChanges, OnInit, 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';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+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';
+import { ServiceFormComponent } from './service-form/service-form.component';
+
+const BASE_URL = 'services';
+
+@Component({
+ selector: 'cd-services',
+ templateUrl: './services.component.html',
+ styleUrls: ['./services.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class ServicesComponent extends ListWithDetails implements OnChanges, OnInit {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+
+ @Input() hostname: string;
+
+ // Do not display these columns
+ @Input() hiddenColumns: string[] = [];
+
+ @Input() hiddenServices: string[] = [];
+
+ @Input() hasDetails = true;
+
+ @Input() routedModal = true;
+
+ permissions: Permissions;
+ tableActions: CdTableAction[];
+ showDocPanel = false;
+ bsModalRef: NgbModalRef;
+
+ orchStatus: OrchestratorStatus;
+ actionOrchFeatures = {
+ create: [OrchestratorFeature.SERVICE_CREATE],
+ update: [OrchestratorFeature.SERVICE_EDIT],
+ delete: [OrchestratorFeature.SERVICE_DELETE]
+ };
+
+ columns: Array<CdTableColumn> = [];
+ services: Array<CephServiceSpec> = [];
+ isLoadingServices = false;
+ selection: CdTableSelection = new CdTableSelection();
+
+ constructor(
+ private actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private modalService: ModalService,
+ private orchService: OrchestratorService,
+ private cephServiceService: CephServiceService,
+ private relativeDatePipe: RelativeDatePipe,
+ private taskWrapperService: TaskWrapperService,
+ private router: Router
+ ) {
+ super();
+ this.permissions = this.authStorageService.getPermissions();
+ this.tableActions = [
+ {
+ permission: 'create',
+ icon: Icons.add,
+ click: () => this.openModal(),
+ name: this.actionLabels.CREATE,
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ disable: (selection: CdTableSelection) => this.getDisable('create', selection)
+ },
+ {
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.openModal(true),
+ name: this.actionLabels.EDIT,
+ disable: (selection: CdTableSelection) => this.getDisable('update', selection)
+ },
+ {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteAction(),
+ name: this.actionLabels.DELETE,
+ disable: (selection: CdTableSelection) => this.getDisable('delete', selection)
+ }
+ ];
+ }
+
+ openModal(edit = false) {
+ if (this.routedModal) {
+ edit
+ ? this.router.navigate([
+ BASE_URL,
+ {
+ outlets: {
+ modal: [
+ URLVerbs.EDIT,
+ this.selection.first().service_type,
+ this.selection.first().service_name
+ ]
+ }
+ }
+ ])
+ : this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]);
+ } else {
+ let initialState = {};
+ edit
+ ? (initialState = {
+ serviceName: this.selection.first()?.service_name,
+ serviceType: this.selection?.first()?.service_type,
+ hiddenServices: this.hiddenServices,
+ editing: edit
+ })
+ : (initialState = {
+ hiddenServices: this.hiddenServices,
+ editing: edit
+ });
+ this.bsModalRef = this.modalService.show(ServiceFormComponent, initialState, { size: 'lg' });
+ }
+ }
+
+ ngOnInit() {
+ const columns = [
+ {
+ name: $localize`Service`,
+ prop: 'service_name',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Placement`,
+ prop: '',
+ pipe: new PlacementPipe(),
+ flexGrow: 2
+ },
+ {
+ name: $localize`Running`,
+ prop: 'status.running',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Size`,
+ prop: 'status.size',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Last Refreshed`,
+ prop: 'status.last_refresh',
+ pipe: this.relativeDatePipe,
+ flexGrow: 1
+ }
+ ];
+
+ this.columns = columns.filter((col: any) => {
+ return !this.hiddenColumns.includes(col.prop);
+ });
+
+ this.orchService.status().subscribe((status: OrchestratorStatus) => {
+ this.orchStatus = status;
+ this.showDocPanel = !status.available;
+ });
+ }
+
+ ngOnChanges() {
+ if (this.orchStatus?.available) {
+ this.services = [];
+ this.table.reloadData();
+ }
+ }
+
+ getDisable(
+ action: 'create' | 'update' | 'delete',
+ selection: CdTableSelection
+ ): boolean | string {
+ if (action === 'delete') {
+ if (!selection?.hasSingleSelection) {
+ return true;
+ }
+ }
+ if (action === 'update') {
+ const disableEditServices = ['osd', 'container'];
+ if (disableEditServices.indexOf(this.selection.first()?.service_type) >= 0) {
+ return true;
+ }
+ }
+ return this.orchService.getTableActionDisableDesc(
+ this.orchStatus,
+ this.actionOrchFeatures[action]
+ );
+ }
+
+ getServices(context: CdTableFetchDataContext) {
+ if (this.isLoadingServices) {
+ return;
+ }
+ this.isLoadingServices = true;
+ this.cephServiceService.list().subscribe(
+ (services: CephServiceSpec[]) => {
+ this.services = services;
+ this.services = this.services.filter((col: any) => {
+ return !this.hiddenServices.includes(col.service_name);
+ });
+ this.isLoadingServices = false;
+ },
+ () => {
+ this.isLoadingServices = false;
+ this.services = [];
+ context.error();
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteAction() {
+ const service = this.selection.first();
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: $localize`Service`,
+ itemNames: [service.service_name],
+ actionDescription: 'delete',
+ submitActionObservable: () =>
+ this.taskWrapperService
+ .wrapTaskAroundCall({
+ task: new FinishedTask(`service/${URLVerbs.DELETE}`, {
+ service_name: service.service_name
+ }),
+ call: this.cephServiceService.delete(service.service_name)
+ })
+ .pipe(
+ // Delay closing the dialog, otherwise the datatable still
+ // shows the deleted service after an auto-reload.
+ // Showing the dialog while delaying is done to increase
+ // the user experience.
+ delay(5000)
+ )
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.html
new file mode 100644
index 000000000..134871469
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.html
@@ -0,0 +1,322 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <ng-container [ngSwitch]="step">
+ <!-- Configuration step -->
+ <div *ngSwitchCase="1">
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="configForm"
+ novalidate>
+ <div class="card">
+ <div class="card-header"
+ i18n>Step {{ step }} of 2: Telemetry report configuration</div>
+ <div class="card-body">
+ <p i18n>The telemetry module sends anonymous data about this Ceph cluster back to the Ceph developers
+ to help understand how Ceph is used and what problems users may be experiencing.<br/>
+ This data is visualized on <a href="https://telemetry-public.ceph.com/">public dashboards</a>
+ that allow the community to quickly see summary statistics on how many clusters are reporting,
+ their total capacity and OSD count, and version distribution trends.<br/><br/>
+ The data being reported does <b>not</b> contain any sensitive data like pool names, object names, object contents,
+ hostnames, or device serial numbers. It contains counters and statistics on how the cluster has been
+ deployed, the version of Ceph, the distribution of the hosts and other parameters which help the project
+ to gain a better understanding of the way Ceph is used. The data is sent secured to {{ sendToUrl }} and
+ {{ sendToDeviceUrl }} (device report).</p>
+ <div *ngIf="moduleEnabled">
+ The plugin is already <b>enabled</b>. Click <b>Deactivate</b> to disable it.&nbsp;
+ <button type="button"
+ class="btn btn-light"
+ (click)="disableModule('The Telemetry module has been disabled successfully.')"
+ i18n>Deactivate</button>
+ </div>
+ <legend i18n>Channels</legend>
+ <p i18n>The telemetry report is broken down into several "channels", each with a different type of information that can
+ be configured below.</p>
+
+ <!-- Channel basic -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="channel_basic">
+ <ng-container i18n>Basic</ng-container>
+ <cd-helper>
+ <ng-container i18n>Includes basic information about the cluster:</ng-container>
+ <ul>
+ <li i18n>Capacity of the cluster</li>
+ <li i18n>Number of monitors, managers, OSDs, MDSs, object gateways, or other daemons</li>
+ <li i18n>Software version currently being used</li>
+ <li i18n>Number and types of RADOS pools and CephFS file systems</li>
+ <li i18n>Names of configuration options that have been changed from their default (but not their values)</li>
+ </ul>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="channel_basic"
+ formControlName="channel_basic">
+ <label class="custom-control-label"
+ for="channel_basic"></label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Channel crash -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="channel_crash">
+ <ng-container i18n>Crash</ng-container>
+ <cd-helper>
+ <ng-container i18n>Includes information about daemon crashes:</ng-container>
+ <ul>
+ <li i18n>Type of daemon</li>
+ <li i18n>Version of the daemon</li>
+ <li i18n>Operating system (OS distribution, kernel version)</li>
+ <li i18n>Stack trace identifying where in the Ceph code the crash occurred</li>
+ </ul>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="channel_crash"
+ formControlName="channel_crash">
+ <label class="custom-control-label"
+ for="channel_crash"></label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Channel device -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="channel_device">
+ <ng-container i18n>Device</ng-container>
+ <cd-helper i18n-html
+ html="Includes information about device metrics like anonymized SMART metrics.">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="channel_device"
+ formControlName="channel_device">
+ <label class="custom-control-label"
+ for="channel_device"></label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Channel ident -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="channel_ident">
+ <ng-container i18n>Ident</ng-container>
+ <cd-helper>
+ <ng-container i18n>Includes user-provided identifying information about the cluster:</ng-container>
+ <ul>
+ <li>Cluster description</li>
+ <li>Contact email address</li>
+ </ul>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="channel_ident"
+ formControlName="channel_ident"
+ (click)="toggleIdent()">
+ <label class="custom-control-label"
+ for="channel_ident"></label>
+ </div>
+ </div>
+ </div>
+ <ng-container *ngIf="showContactInfo">
+ <legend>
+ <ng-container i18n>Contact Information</ng-container>
+ <cd-helper i18n>Submitting any contact information is completely optional and disabled by default.</cd-helper>
+ </legend>
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="contact"
+ i18n>Contact</label>
+ <div class="cd-col-form-input">
+ <input id="contact"
+ class="form-control"
+ type="text"
+ formControlName="contact"
+ placeholder="Example User <user@example.com>">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="description"
+ i18n>Description</label>
+ <div class="cd-col-form-input">
+ <input id="description"
+ class="form-control"
+ type="text"
+ formControlName="description"
+ placeholder="My first Ceph cluster"
+ i18n-placeholder>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="organization"
+ i18n>Organization</label>
+ <div class="cd-col-form-input">
+ <input id="organization"
+ class="form-control"
+ type="text"
+ formControlName="organization"
+ placeholder="Organization name"
+ i18n-placeholder>
+ </div>
+ </div>
+ </ng-container>
+ <legend i18n>Advanced Settings</legend>
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="interval">
+ <ng-container i18n>Interval</ng-container>
+ <cd-helper i18n>The module compiles and sends a new report every 24 hours by default. You can
+ adjust this interval by setting a different number of hours.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="interval"
+ class="form-control"
+ type="number"
+ formControlName="interval"
+ min="8">
+ <span class="invalid-feedback"
+ *ngIf="configForm.showError('interval', formDir, 'min')"
+ i18n>The entered value is too low! It must be greater or equal to 8.</span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="proxy">
+ <ng-container i18n>Proxy</ng-container>
+ <cd-helper>
+ <p i18n>If the cluster cannot directly connect to the configured telemetry endpoint
+ (default telemetry.ceph.com), you can configure a HTTP/HTTPS proxy server by e.g. adding
+ https://10.0.0.1:8080</p>
+ <p i18n>You can also include a user:pass if needed e.g. https://ceph:telemetry@10.0.0.1:8080</p>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="proxy"
+ class="form-control"
+ type="text"
+ formControlName="proxy"
+ placeholder="https://10.0.0.1:8080">
+ </div>
+ </div>
+ <br />
+ <p i18n><b>Note:</b> By clicking 'Next' you will first see a preview of the report content before you
+ can activate the automatic submission of your data.</p>
+ </div>
+ <div class="card-footer">
+ <div class="button-group text-right">
+ <button type="button"
+ class="btn btn-light"
+ (click)="next()">
+ <ng-container>{{ actionLabels.NEXT }}</ng-container>
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <!-- Preview step -->
+ <div *ngSwitchCase="2">
+ <form name="previewForm"
+ #frm="ngForm"
+ [formGroup]="previewForm"
+ novalidate>
+ <div class="card">
+ <div class="card-header"
+ i18n>Step {{ step }} of 2: Telemetry report preview</div>
+ <div class="card-body">
+ <!-- Telemetry report ID -->
+ <div class="form-group row">
+ <label i18n
+ for="reportId"
+ class="cd-col-form-label">Report ID
+ <cd-helper i18n-html
+ html="A randomized UUID to identify a particular cluster over the course of several telemetry reports.">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ id="reportId"
+ formControlName="reportId"
+ readonly>
+ </div>
+ </div>
+
+ <!-- Telemetry report -->
+ <div class="form-group row">
+ <label i18n
+ for="report"
+ class="cd-col-form-label">Report preview
+ <cd-helper i18n-html
+ html="The actual telemetry data that will be submitted.">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <textarea class="form-control"
+ id="report"
+ formControlName="report"
+ rows="15"
+ readonly></textarea>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="btn-group"
+ role="group">
+ <cd-download-button [objectItem]="report"
+ fileName="telemetry_report">
+ </cd-download-button>
+ <cd-copy-2-clipboard-button source="report">
+ </cd-copy-2-clipboard-button>
+ </div>
+ </div>
+ </div>
+
+ <!-- License agreement -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="licenseAgrmt"
+ name="licenseAgrmt"
+ formControlName="licenseAgrmt">
+ <label class="custom-control-label"
+ for="licenseAgrmt"
+ i18n>I agree to my telemetry data being submitted under the <a href="https://cdla.io/sharing-1-0/">Community Data License Agreement - Sharing - Version 1.0</a></label>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="card-footer">
+ <div class="button-group text-right">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ (backActionEvent)="back()"
+ [form]="previewForm"
+ [submitText]="actionLabels.UPDATE"
+ [cancelText]="actionLabels.BACK"></cd-form-button-panel>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+ </ng-container>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.spec.ts
new file mode 100644
index 000000000..248881649
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.spec.ts
@@ -0,0 +1,188 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { of as observableOf } from 'rxjs';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { DownloadButtonComponent } from '~/app/shared/components/download-button/download-button.component';
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TelemetryComponent } from './telemetry.component';
+
+describe('TelemetryComponent', () => {
+ let component: TelemetryComponent;
+ let fixture: ComponentFixture<TelemetryComponent>;
+ let mgrModuleService: MgrModuleService;
+ let options: any;
+ let configs: any;
+ let httpTesting: HttpTestingController;
+ let router: Router;
+
+ const optionsNames = [
+ 'channel_basic',
+ 'channel_crash',
+ 'channel_device',
+ 'channel_ident',
+ 'contact',
+ 'description',
+ 'device_url',
+ 'enabled',
+ 'interval',
+ 'last_opt_revision',
+ 'leaderboard',
+ 'log_level',
+ 'log_to_cluster',
+ 'log_to_cluster_level',
+ 'log_to_file',
+ 'organization',
+ 'proxy',
+ 'url'
+ ];
+
+ configureTestBed(
+ {
+ declarations: [TelemetryComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ },
+ [LoadingPanelComponent, DownloadButtonComponent]
+ );
+
+ describe('configForm', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TelemetryComponent);
+ component = fixture.componentInstance;
+ mgrModuleService = TestBed.inject(MgrModuleService);
+ options = {};
+ configs = {};
+ optionsNames.forEach((name) => (options[name] = { name }));
+ optionsNames.forEach((name) => (configs[name] = true));
+ spyOn(mgrModuleService, 'getOptions').and.callFake(() => observableOf(options));
+ spyOn(mgrModuleService, 'getConfig').and.callFake(() => observableOf(configs));
+ fixture.detectChanges();
+ httpTesting = TestBed.inject(HttpTestingController);
+ router = TestBed.inject(Router);
+ spyOn(router, 'navigate');
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should show/hide ident fields on checking/unchecking', () => {
+ const getContactField = () =>
+ fixture.debugElement.nativeElement.querySelector('input[id=contact]');
+ const getDescriptionField = () =>
+ fixture.debugElement.nativeElement.querySelector('input[id=description]');
+ const checkVisibility = () => {
+ if (component.showContactInfo) {
+ expect(getContactField()).toBeTruthy();
+ expect(getDescriptionField()).toBeTruthy();
+ } else {
+ expect(getContactField()).toBeFalsy();
+ expect(getDescriptionField()).toBeFalsy();
+ }
+ };
+
+ // Initial check.
+ checkVisibility();
+
+ // toggle fields.
+ component.toggleIdent();
+ fixture.detectChanges();
+ checkVisibility();
+
+ // toggle fields again.
+ component.toggleIdent();
+ fixture.detectChanges();
+ checkVisibility();
+ });
+
+ it('should set module enability to true correctly', () => {
+ expect(component.moduleEnabled).toBeTruthy();
+ });
+
+ it('should set module enability to false correctly', () => {
+ configs['enabled'] = false;
+ component.ngOnInit();
+ expect(component.moduleEnabled).toBeFalsy();
+ });
+
+ it('should filter options list correctly', () => {
+ _.forEach(Object.keys(component.options), (option) => {
+ expect(component.requiredFields).toContain(option);
+ });
+ });
+
+ it('should disable the Telemetry module', () => {
+ const message = 'Module disabled message.';
+ const followUpFunc = function () {
+ return 'followUp';
+ };
+ component.disableModule(message, followUpFunc);
+ const req = httpTesting.expectOne('api/telemetry');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({
+ enable: false
+ });
+ req.flush({});
+ });
+
+ it('should disable the Telemetry module with default parameters', () => {
+ component.disableModule();
+ const req = httpTesting.expectOne('api/telemetry');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({
+ enable: false
+ });
+ req.flush({});
+ expect(router.navigate).toHaveBeenCalledWith(['']);
+ });
+ });
+
+ describe('previewForm', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TelemetryComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ httpTesting = TestBed.inject(HttpTestingController);
+ router = TestBed.inject(Router);
+ spyOn(router, 'navigate');
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should submit', () => {
+ component.onSubmit();
+ const req1 = httpTesting.expectOne('api/telemetry');
+ expect(req1.request.method).toBe('PUT');
+ expect(req1.request.body).toEqual({
+ enable: true,
+ license_name: 'sharing-1-0'
+ });
+ req1.flush({});
+ const req2 = httpTesting.expectOne({
+ url: 'api/mgr/module/telemetry',
+ method: 'PUT'
+ });
+ expect(req2.request.body).toEqual({
+ config: {}
+ });
+ req2.flush({});
+ expect(router.url).toBe('/');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.ts
new file mode 100644
index 000000000..c2f4523dc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/telemetry/telemetry.component.ts
@@ -0,0 +1,244 @@
+import { Component, OnInit } from '@angular/core';
+import { ValidatorFn, Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import _ from 'lodash';
+import { forkJoin as observableForkJoin } from 'rxjs';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { TelemetryService } from '~/app/shared/api/telemetry.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service';
+
+@Component({
+ selector: 'cd-telemetry',
+ templateUrl: './telemetry.component.html',
+ styleUrls: ['./telemetry.component.scss']
+})
+export class TelemetryComponent extends CdForm implements OnInit {
+ configForm: CdFormGroup;
+ licenseAgrmt = false;
+ moduleEnabled: boolean;
+ options: Object = {};
+ newConfig: Object = {};
+ configResp: object = {};
+ previewForm: CdFormGroup;
+ requiredFields = [
+ 'channel_basic',
+ 'channel_crash',
+ 'channel_device',
+ 'channel_ident',
+ 'interval',
+ 'proxy',
+ 'contact',
+ 'description',
+ 'organization'
+ ];
+ contactInfofields = ['contact', 'description', 'organization'];
+ report: object = undefined;
+ reportId: number = undefined;
+ sendToUrl = '';
+ sendToDeviceUrl = '';
+ step = 1;
+ showContactInfo: boolean;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private formBuilder: CdFormBuilder,
+ private mgrModuleService: MgrModuleService,
+ private notificationService: NotificationService,
+ private router: Router,
+ private telemetryService: TelemetryService,
+ private telemetryNotificationService: TelemetryNotificationService
+ ) {
+ super();
+ }
+
+ ngOnInit() {
+ const observables = [
+ this.mgrModuleService.getOptions('telemetry'),
+ this.mgrModuleService.getConfig('telemetry')
+ ];
+ observableForkJoin(observables).subscribe(
+ (resp: object) => {
+ const configResp = resp[1];
+ this.moduleEnabled = configResp['enabled'];
+ this.sendToUrl = configResp['url'];
+ this.sendToDeviceUrl = configResp['device_url'];
+ this.showContactInfo = configResp['channel_ident'];
+ this.options = _.pick(resp[0], this.requiredFields);
+ this.configResp = _.pick(configResp, this.requiredFields);
+ this.createConfigForm();
+ this.configForm.setValue(this.configResp);
+ this.loadingReady();
+ },
+ (_error) => {
+ this.loadingError();
+ }
+ );
+ }
+
+ private createConfigForm() {
+ const controlsConfig = {};
+ _.forEach(Object.values(this.options), (option) => {
+ controlsConfig[option.name] = [option.default_value, this.getValidators(option)];
+ });
+ this.configForm = this.formBuilder.group(controlsConfig);
+ }
+
+ private createPreviewForm() {
+ const controls = {
+ report: JSON.stringify(this.report, null, 2),
+ reportId: this.reportId,
+ licenseAgrmt: [this.licenseAgrmt, Validators.requiredTrue]
+ };
+ this.previewForm = this.formBuilder.group(controls);
+ }
+
+ private getValidators(option: any): ValidatorFn[] {
+ const result = [];
+ switch (option.type) {
+ case 'int':
+ result.push(Validators.required);
+ break;
+ case 'str':
+ if (_.isNumber(option.min)) {
+ result.push(Validators.minLength(option.min));
+ }
+ if (_.isNumber(option.max)) {
+ result.push(Validators.maxLength(option.max));
+ }
+ break;
+ }
+ return result;
+ }
+
+ private updateReportFromConfig(updatedConfig: Object = {}) {
+ // update channels
+ const availableChannels: string[] = this.report['report']['channels_available'];
+ const updatedChannels = [];
+ for (const channel of availableChannels) {
+ const key = `channel_${channel}`;
+ if (updatedConfig[key]) {
+ updatedChannels.push(channel);
+ }
+ }
+ this.report['report']['channels'] = updatedChannels;
+ // update contactInfo
+ for (const contactInfofield of this.contactInfofields) {
+ this.report['report'][contactInfofield] = updatedConfig[contactInfofield];
+ }
+ }
+
+ private getReport() {
+ this.loadingStart();
+
+ this.telemetryService.getReport().subscribe(
+ (resp: object) => {
+ this.report = resp;
+ this.reportId = resp['report']['report_id'];
+ this.updateReportFromConfig(this.newConfig);
+ this.createPreviewForm();
+ this.loadingReady();
+ this.step++;
+ },
+ (_error) => {
+ this.loadingError();
+ }
+ );
+ }
+
+ toggleIdent() {
+ this.showContactInfo = !this.showContactInfo;
+ }
+
+ buildReport() {
+ this.newConfig = {};
+ for (const option of Object.values(this.options)) {
+ const control = this.configForm.get(option.name);
+ // Append the option only if they are valid
+ if (control.valid) {
+ this.newConfig[option.name] = control.value;
+ } else {
+ this.configForm.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ }
+ // reset contact info field if ident channel is off
+ if (!this.newConfig['channel_ident']) {
+ for (const contactInfofield of this.contactInfofields) {
+ this.newConfig[contactInfofield] = '';
+ }
+ }
+ this.getReport();
+ }
+
+ disableModule(message: string = null, followUpFunc: Function = null) {
+ this.telemetryService.enable(false).subscribe(() => {
+ this.telemetryNotificationService.setVisibility(true);
+ if (message) {
+ this.notificationService.show(NotificationType.success, message);
+ }
+ if (followUpFunc) {
+ followUpFunc();
+ } else {
+ this.router.navigate(['']);
+ }
+ });
+ }
+
+ next() {
+ this.buildReport();
+ }
+
+ back() {
+ this.step--;
+ }
+
+ getChangedConfig() {
+ const updatedConfig = {};
+ _.forEach(this.requiredFields, (configField) => {
+ if (!_.isEqual(this.configResp[configField], this.newConfig[configField])) {
+ updatedConfig[configField] = this.newConfig[configField];
+ }
+ });
+ return updatedConfig;
+ }
+
+ onSubmit() {
+ const updatedConfig = this.getChangedConfig();
+ const observables = [
+ this.telemetryService.enable(),
+ this.mgrModuleService.updateConfig('telemetry', updatedConfig)
+ ];
+
+ observableForkJoin(observables).subscribe(
+ () => {
+ this.telemetryNotificationService.setVisibility(false);
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`The Telemetry module has been configured and activated successfully.`
+ );
+ },
+ () => {
+ this.telemetryNotificationService.setVisibility(false);
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`An Error occurred while updating the Telemetry module configuration.\
+ Please Try again`
+ );
+ // Reset the 'Update' button.
+ this.previewForm.setErrors({ cdSubmitButton: true });
+ },
+ () => {
+ this.newConfig = {};
+ this.router.navigate(['']);
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts
new file mode 100644
index 000000000..1205de94d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts
@@ -0,0 +1,43 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { NgbNavModule, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+import { ChartsModule } from 'ng2-charts';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
+import { DashboardComponent } from './dashboard/dashboard.component';
+import { HealthPieComponent } from './health-pie/health-pie.component';
+import { HealthComponent } from './health/health.component';
+import { InfoCardComponent } from './info-card/info-card.component';
+import { InfoGroupComponent } from './info-group/info-group.component';
+import { MdsSummaryPipe } from './mds-summary.pipe';
+import { MgrSummaryPipe } from './mgr-summary.pipe';
+import { MonSummaryPipe } from './mon-summary.pipe';
+import { OsdSummaryPipe } from './osd-summary.pipe';
+
+@NgModule({
+ imports: [
+ CephSharedModule,
+ CommonModule,
+ NgbNavModule,
+ SharedModule,
+ ChartsModule,
+ RouterModule,
+ NgbPopoverModule
+ ],
+
+ declarations: [
+ HealthComponent,
+ DashboardComponent,
+ MonSummaryPipe,
+ OsdSummaryPipe,
+ MgrSummaryPipe,
+ MdsSummaryPipe,
+ HealthPieComponent,
+ InfoCardComponent,
+ InfoGroupComponent
+ ]
+})
+export class DashboardModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html
new file mode 100644
index 000000000..2d03ea3e6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html
@@ -0,0 +1,28 @@
+<div>
+ <cd-refresh-selector></cd-refresh-selector>
+
+ <ng-container *ngIf="hasGrafana">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem>
+ <a ngbNavLink
+ i18n>Health</a>
+ <ng-template ngbNavContent>
+ <cd-health></cd-health>
+ </ng-template>
+ </li>
+ <li ngbNavItem>
+ <a ngbNavLink
+ i18n>Statistics</a>
+ <ng-template ngbNavContent>
+ </ng-template>
+ </li>
+
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+ </ng-container>
+
+ <cd-health *ngIf="!hasGrafana"></cd-health>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss
new file mode 100644
index 000000000..04eee2d6f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss
@@ -0,0 +1,3 @@
+div {
+ padding-top: 20px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts
new file mode 100644
index 000000000..7bc4980bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts
@@ -0,0 +1,28 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DashboardComponent } from './dashboard.component';
+
+describe('DashboardComponent', () => {
+ let component: DashboardComponent;
+ let fixture: ComponentFixture<DashboardComponent>;
+
+ configureTestBed({
+ imports: [NgbNavModule],
+ declarations: [DashboardComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DashboardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts
new file mode 100644
index 000000000..354e38903
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'cd-dashboard',
+ templateUrl: './dashboard.component.html',
+ styleUrls: ['./dashboard.component.scss']
+})
+export class DashboardComponent {
+ hasGrafana = false; // TODO: Temporary var, remove when grafana is implemented
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.html
new file mode 100644
index 000000000..ba8176bea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.html
@@ -0,0 +1,16 @@
+<div class="chart-container">
+ <canvas baseChart
+ #chartCanvas
+ [datasets]="chartConfig.dataset"
+ [chartType]="chartConfig.chartType"
+ [options]="chartConfig.options"
+ [labels]="chartConfig.labels"
+ [colors]="chartConfig.colors"
+ [plugins]="doughnutChartPlugins"
+ class="chart-canvas">
+ </canvas>
+ <div class="chartjs-tooltip"
+ #chartTooltip>
+ <table></table>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.scss
new file mode 100644
index 000000000..64e7a9822
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.scss
@@ -0,0 +1,22 @@
+@use './src/styles/chart-tooltip';
+
+$canvas-width: 100%;
+$canvas-height: 100%;
+
+.chart-container {
+ height: $canvas-height;
+ margin-left: auto;
+ margin-right: auto;
+ position: unset;
+ width: $canvas-width;
+}
+
+.chart-canvas {
+ height: $canvas-height;
+ margin-left: auto;
+ margin-right: auto;
+ max-height: $canvas-height;
+ max-width: $canvas-width;
+ position: unset;
+ width: $canvas-width;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.spec.ts
new file mode 100644
index 000000000..b87f4bfb5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.spec.ts
@@ -0,0 +1,75 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HealthPieComponent } from './health-pie.component';
+
+describe('HealthPieComponent', () => {
+ let component: HealthPieComponent;
+ let fixture: ComponentFixture<HealthPieComponent>;
+
+ configureTestBed({
+ schemas: [NO_ERRORS_SCHEMA],
+ declarations: [HealthPieComponent],
+ providers: [DimlessBinaryPipe, DimlessPipe, FormatterService, CssHelper]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(HealthPieComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('Add slice border if there is more than one slice with numeric non zero value', () => {
+ component.chartConfig.dataset[0].data = [48, 0, 1, 0];
+ component.ngOnChanges();
+
+ expect(component.chartConfig.dataset[0].borderWidth).toEqual(1);
+ });
+
+ it('Remove slice border if there is only one slice with numeric non zero value', () => {
+ component.chartConfig.dataset[0].data = [48, 0, undefined, 0];
+ component.ngOnChanges();
+
+ expect(component.chartConfig.dataset[0].borderWidth).toEqual(0);
+ });
+
+ it('Remove slice border if there is no slice with numeric non zero value', () => {
+ component.chartConfig.dataset[0].data = [undefined, 0];
+ component.ngOnChanges();
+
+ expect(component.chartConfig.dataset[0].borderWidth).toEqual(0);
+ });
+
+ it('should not hide any slice if there is no user click on legend item', () => {
+ const initialData = [8, 15];
+ component.chartConfig.dataset[0].data = initialData;
+ component.ngOnChanges();
+
+ expect(component.chartConfig.dataset[0].data).toEqual(initialData);
+ });
+
+ describe('tooltip body', () => {
+ const tooltipBody = ['text: 10000'];
+
+ it('should return amount converted to appropriate units', () => {
+ component.isBytesData = false;
+ expect(component['getChartTooltipBody'](tooltipBody)).toEqual('text: 10 k');
+
+ component.isBytesData = true;
+ expect(component['getChartTooltipBody'](tooltipBody)).toEqual('text: 9.8 KiB');
+ });
+
+ it('should not return amount when showing label as tooltip', () => {
+ component.showLabelAsTooltip = true;
+ expect(component['getChartTooltipBody'](tooltipBody)).toEqual('text');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.ts
new file mode 100644
index 000000000..fc119b6e2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health-pie/health-pie.component.ts
@@ -0,0 +1,198 @@
+import {
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnInit,
+ Output,
+ ViewChild
+} from '@angular/core';
+
+import * as Chart from 'chart.js';
+import _ from 'lodash';
+import { PluginServiceGlobalRegistrationAndOptions } from 'ng2-charts';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { ChartTooltip } from '~/app/shared/models/chart-tooltip';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+
+@Component({
+ selector: 'cd-health-pie',
+ templateUrl: './health-pie.component.html',
+ styleUrls: ['./health-pie.component.scss']
+})
+export class HealthPieComponent implements OnChanges, OnInit {
+ @ViewChild('chartCanvas', { static: true })
+ chartCanvasRef: ElementRef;
+ @ViewChild('chartTooltip', { static: true })
+ chartTooltipRef: ElementRef;
+
+ @Input()
+ data: any;
+ @Input()
+ config = {};
+ @Input()
+ isBytesData = false;
+ @Input()
+ tooltipFn: any;
+ @Input()
+ showLabelAsTooltip = false;
+ @Output()
+ prepareFn = new EventEmitter();
+
+ chartConfig: any = {
+ chartType: 'doughnut',
+ dataset: [
+ {
+ label: null,
+ borderWidth: 0
+ }
+ ],
+ colors: [
+ {
+ backgroundColor: [
+ this.cssHelper.propertyValue('chart-color-green'),
+ this.cssHelper.propertyValue('chart-color-yellow'),
+ this.cssHelper.propertyValue('chart-color-orange'),
+ this.cssHelper.propertyValue('chart-color-red'),
+ this.cssHelper.propertyValue('chart-color-blue')
+ ]
+ }
+ ],
+ options: {
+ cutoutPercentage: 90,
+ events: ['click', 'mouseout', 'touchstart'],
+ legend: {
+ display: true,
+ position: 'right',
+ labels: {
+ boxWidth: 10,
+ usePointStyle: false
+ }
+ },
+ plugins: {
+ center_text: true
+ },
+ tooltips: {
+ enabled: true,
+ displayColors: false,
+ backgroundColor: this.cssHelper.propertyValue('chart-color-tooltip-background'),
+ cornerRadius: 0,
+ bodyFontSize: 14,
+ bodyFontStyle: '600',
+ position: 'nearest',
+ xPadding: 12,
+ yPadding: 12,
+ callbacks: {
+ label: (item: Record<string, any>, data: Record<string, any>) => {
+ let text = data.labels[item.index];
+ if (!text.includes('%')) {
+ text = `${text} (${data.datasets[item.datasetIndex].data[item.index]}%)`;
+ }
+ return text;
+ }
+ }
+ },
+ title: {
+ display: false
+ }
+ }
+ };
+
+ public doughnutChartPlugins: PluginServiceGlobalRegistrationAndOptions[] = [
+ {
+ id: 'center_text',
+ beforeDraw(chart: Chart) {
+ const cssHelper = new CssHelper();
+ const defaultFontFamily = 'Helvetica Neue, Helvetica, Arial, sans-serif';
+ Chart.defaults.global.defaultFontFamily = defaultFontFamily;
+ const ctx = chart.ctx;
+ if (!chart.options.plugins.center_text || !chart.data.datasets[0].label) {
+ return;
+ }
+
+ ctx.save();
+ const label = chart.data.datasets[0].label.split('\n');
+
+ const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
+ const centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ ctx.font = `24px ${defaultFontFamily}`;
+ ctx.fillStyle = cssHelper.propertyValue('chart-color-center-text');
+ ctx.fillText(label[0], centerX, centerY - 10);
+
+ if (label.length > 1) {
+ ctx.font = `14px ${defaultFontFamily}`;
+ ctx.fillStyle = cssHelper.propertyValue('chart-color-center-text-description');
+ ctx.fillText(label[1], centerX, centerY + 10);
+ }
+ ctx.restore();
+ }
+ }
+ ];
+
+ constructor(
+ private dimlessBinary: DimlessBinaryPipe,
+ private dimless: DimlessPipe,
+ private cssHelper: CssHelper
+ ) {}
+
+ ngOnInit() {
+ const getStyleTop = (tooltip: any, positionY: number) => {
+ return positionY + tooltip.caretY - tooltip.height - 10 + 'px';
+ };
+
+ const getStyleLeft = (tooltip: any, positionX: number) => {
+ return positionX + tooltip.caretX + 'px';
+ };
+
+ const chartTooltip = new ChartTooltip(
+ this.chartCanvasRef,
+ this.chartTooltipRef,
+ getStyleLeft,
+ getStyleTop
+ );
+
+ chartTooltip.getBody = (body: any) => {
+ return this.getChartTooltipBody(body);
+ };
+
+ _.merge(this.chartConfig, this.config);
+
+ this.prepareFn.emit([this.chartConfig, this.data]);
+ }
+
+ ngOnChanges() {
+ this.prepareFn.emit([this.chartConfig, this.data]);
+ this.setChartSliceBorderWidth();
+ }
+
+ private getChartTooltipBody(body: string[]) {
+ const bodySplit = body[0].split(': ');
+
+ if (this.showLabelAsTooltip) {
+ return bodySplit[0];
+ }
+
+ bodySplit[1] = this.isBytesData
+ ? this.dimlessBinary.transform(bodySplit[1])
+ : this.dimless.transform(bodySplit[1]);
+
+ return bodySplit.join(': ');
+ }
+
+ private setChartSliceBorderWidth() {
+ let nonZeroValueSlices = 0;
+ _.forEach(this.chartConfig.dataset[0].data, function (slice) {
+ if (slice > 0) {
+ nonZeroValueSlices += 1;
+ }
+ });
+
+ this.chartConfig.dataset[0].borderWidth = nonZeroValueSlices > 1 ? 1 : 0;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html
new file mode 100644
index 000000000..71aac66d9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html
@@ -0,0 +1,237 @@
+<div *ngIf="healthData && enabledFeature$ | async as enabledFeature"
+ class="container-fluid">
+ <cd-info-group groupTitle="Status"
+ i18n-groupTitle
+ *ngIf="healthData.health?.status
+ || healthData.mon_status
+ || healthData.osd_map
+ || healthData.mgr_map
+ || healthData.hosts != null
+ || healthData.rgw != null
+ || healthData.fs_map
+ || healthData.iscsi_daemons != null">
+
+ <cd-info-card cardTitle="Cluster Status"
+ i18n-cardTitle
+ class="cd-status-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.health?.status">
+ <ng-container *ngIf="healthData.health?.checks?.length > 0">
+ <ng-template #healthChecks>
+ <ng-container *ngTemplateOutlet="logsLink"></ng-container>
+ <ul>
+ <li *ngFor="let check of healthData.health.checks">
+ <span [ngStyle]="check.severity | healthColor">{{ check.type }}</span>: {{ check.summary.message }}
+ </li>
+ </ul>
+ </ng-template>
+ <div class="info-card-content-clickable"
+ [ngStyle]="healthData.health.status | healthColor"
+ [ngbPopover]="healthChecks"
+ popoverClass="info-card-popover-cluster-status">
+ {{ healthData.health.status }} <i *ngIf="healthData.health?.status != 'HEALTH_OK'"
+ class="fa fa-exclamation-triangle"></i>
+ </div>
+ </ng-container>
+ <ng-container *ngIf="!healthData.health?.checks?.length">
+ <div [ngStyle]="healthData.health.status | healthColor">
+ {{ healthData.health.status }}
+ </div>
+ </ng-container>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Hosts"
+ i18n-cardTitle
+ link="/hosts"
+ class="cd-status-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.hosts != null">
+ {{ healthData.hosts }} total
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Monitors"
+ i18n-cardTitle
+ link="/monitor"
+ class="cd-status-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.mon_status">
+ {{ healthData.mon_status | monSummary }}
+ </cd-info-card>
+
+ <cd-info-card cardTitle="OSDs"
+ i18n-cardTitle
+ link="/osd"
+ class="cd-status-card"
+ *ngIf="(healthData.osd_map | osdSummary) as transformedResult"
+ contentClass="content-highlight">
+ <span *ngFor="let result of transformedResult"
+ [ngClass]="result.class">
+ {{ result.content }}
+ </span>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Managers"
+ i18n-cardTitle
+ class="cd-status-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.mgr_map">
+ <span *ngFor="let result of (healthData.mgr_map | mgrSummary)"
+ [ngClass]="result.class"
+ [title]="result.titleText != null ? result.titleText : ''">
+ {{ result.content }}
+ </span>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Object Gateways"
+ i18n-cardTitle
+ link="/rgw/daemon"
+ class="cd-status-card"
+ contentClass="content-highlight"
+ *ngIf="enabledFeature.rgw && healthData.rgw != null">
+ {{ healthData.rgw }} total
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Metadata Servers"
+ i18n-cardTitle
+ class="cd-status-card"
+ *ngIf="(enabledFeature.cephfs && healthData.fs_map | mdsSummary) as transformedResult"
+ [contentClass]="(transformedResult.length > 1 ? 'text-area-size-2' : '') + ' content-highlight'">
+ <!-- TODO: check text-area-size-2 -->
+ <span *ngFor="let result of transformedResult"
+ [ngClass]="result.class"
+ [title]="result.titleText != null ? result.titleText : ''">
+ {{ result.content }}
+ </span>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="iSCSI Gateways"
+ i18n-cardTitle
+ link="/block/iscsi"
+ class="cd-status-card"
+ contentClass="content-highlight"
+ *ngIf="enabledFeature.iscsi && healthData.iscsi_daemons != null">
+ {{ healthData.iscsi_daemons.up + healthData.iscsi_daemons.down }} total
+ <span class="card-text-line-break"></span>
+ {{ healthData.iscsi_daemons.up }} up,
+ <span [ngClass]="{'card-text-error': healthData.iscsi_daemons.down > 0}">{{ healthData.iscsi_daemons.down }}
+ down</span>
+ </cd-info-card>
+ </cd-info-group>
+
+ <cd-info-group groupTitle="Capacity"
+ i18n-groupTitle
+ *ngIf="healthData.pools
+ || healthData.df
+ || healthData.pg_info">
+ <cd-info-card cardTitle="Raw Capacity"
+ i18n-cardTitle
+ class="cd-capacity-card cd-chart-card"
+ contentClass="content-chart"
+ *ngIf="healthData.df">
+ <cd-health-pie [data]="healthData"
+ [config]="rawCapacityChartConfig"
+ [isBytesData]="true"
+ (prepareFn)="prepareRawUsage($event[0], $event[1])">
+ </cd-health-pie>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Objects"
+ i18n-cardTitle
+ class="cd-capacity-card cd-chart-card"
+ contentClass="content-chart"
+ *ngIf="healthData.pg_info?.object_stats?.num_objects != null">
+ <cd-health-pie [data]="healthData"
+ (prepareFn)="prepareObjects($event[0], $event[1])">
+ </cd-health-pie>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="PG Status"
+ i18n-cardTitle
+ class="cd-capacity-card cd-chart-card"
+ contentClass="content-chart"
+ *ngIf="healthData.pg_info">
+ <ng-template #pgStatus>
+ <ng-container *ngTemplateOutlet="logsLink"></ng-container>
+ <ul>
+ <li *ngFor="let pgStatesText of healthData.pg_info.statuses | keyvalue">
+ {{ pgStatesText.key }}: {{ pgStatesText.value }}
+ </li>
+ </ul>
+ </ng-template>
+ <div class="pg-status-popover-wrapper">
+ <div [ngbPopover]="pgStatus">
+ <cd-health-pie [data]="healthData"
+ [config]="pgStatusChartConfig"
+ (prepareFn)="preparePgStatus($event[0], $event[1])">
+ </cd-health-pie>
+ </div>
+ </div>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Pools"
+ i18n-cardTitle
+ link="/pool"
+ class="cd-capacity-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.pools">
+ {{ healthData.pools.length }}
+ </cd-info-card>
+
+ <cd-info-card cardTitle="PGs per OSD"
+ i18n-cardTitle
+ class="cd-capacity-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.pg_info">
+ {{ healthData.pg_info.pgs_per_osd | dimless }}
+ </cd-info-card>
+ </cd-info-group>
+
+ <cd-info-group groupTitle="Performance"
+ i18n-groupTitle
+ *ngIf="healthData.client_perf || healthData.scrub_status">
+ <cd-info-card cardTitle="Client Read/Write"
+ i18n-cardTitle
+ class="cd-performance-card cd-chart-card"
+ contentClass="content-chart"
+ *ngIf="healthData.client_perf">
+ <cd-health-pie [data]="healthData"
+ [config]="clientStatsConfig"
+ (prepareFn)="prepareReadWriteRatio($event[0], $event[1])">
+ </cd-health-pie>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Client Throughput"
+ i18n-cardTitle
+ class="cd-performance-card cd-chart-card"
+ contentClass="content-chart"
+ *ngIf="healthData.client_perf">
+ <cd-health-pie [data]="healthData"
+ [config]="clientStatsConfig"
+ (prepareFn)="prepareClientThroughput($event[0], $event[1])">
+ </cd-health-pie>
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Recovery Throughput"
+ i18n-cardTitle
+ class="cd-performance-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.client_perf">
+ {{ (healthData.client_perf.recovering_bytes_per_sec | dimlessBinary) + '/s' }}
+ </cd-info-card>
+
+ <cd-info-card cardTitle="Scrubbing"
+ i18n-cardTitle
+ class="cd-performance-card"
+ contentClass="content-highlight"
+ *ngIf="healthData.scrub_status">
+ {{ healthData.scrub_status }}
+ </cd-info-card>
+ </cd-info-group>
+
+ <ng-template #logsLink>
+ <ng-container *ngIf="permissions.log.read">
+ <p class="logs-link"
+ i18n><i [ngClass]="[icons.infoCircle]"></i> See <a routerLink="/logs">Logs</a> for more details.</p>
+ </ng-container>
+ </ng-template>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.scss
new file mode 100644
index 000000000..1294f5922
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.scss
@@ -0,0 +1,41 @@
+@use './src/styles/vendor/variables' as vv;
+
+cd-info-card {
+ padding: 0 0.5vw;
+}
+
+::ng-deep cd-health .pg-status-popover-wrapper {
+ position: relative;
+
+ .popover {
+ max-height: 20vh;
+ max-width: unset !important;
+ min-width: unset !important;
+ position: absolute;
+ width: 116%;
+
+ .popover-body {
+ font-size: 1rem;
+ max-height: 19vh;
+ max-width: 100%;
+ }
+ }
+}
+
+.logs-link {
+ text-align: center;
+}
+
+.card-text-error {
+ color: vv.$chart-danger;
+ display: inline;
+}
+
+.card-text-line-break::after {
+ content: '\A';
+ white-space: pre;
+}
+
+.popover-info:hover {
+ cursor: pointer;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts
new file mode 100644
index 000000000..cedcd06b6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts
@@ -0,0 +1,348 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import _ from 'lodash';
+import { of } from 'rxjs';
+
+import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
+import { HealthService } from '~/app/shared/api/health.service';
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { FeatureTogglesService } from '~/app/shared/services/feature-toggles.service';
+import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HealthPieComponent } from '../health-pie/health-pie.component';
+import { MdsSummaryPipe } from '../mds-summary.pipe';
+import { MgrSummaryPipe } from '../mgr-summary.pipe';
+import { MonSummaryPipe } from '../mon-summary.pipe';
+import { OsdSummaryPipe } from '../osd-summary.pipe';
+import { HealthComponent } from './health.component';
+
+describe('HealthComponent', () => {
+ let component: HealthComponent;
+ let fixture: ComponentFixture<HealthComponent>;
+ let getHealthSpy: jasmine.Spy;
+ const healthPayload: Record<string, any> = {
+ health: { status: 'HEALTH_OK' },
+ mon_status: { monmap: { mons: [] }, quorum: [] },
+ osd_map: { osds: [] },
+ mgr_map: { standbys: [] },
+ hosts: 0,
+ rgw: 0,
+ fs_map: { filesystems: [], standbys: [] },
+ iscsi_daemons: 0,
+ client_perf: {},
+ scrub_status: 'Inactive',
+ pools: [],
+ df: { stats: {} },
+ pg_info: { object_stats: { num_objects: 0 } }
+ };
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({ log: ['read'] });
+ }
+ };
+ let fakeFeatureTogglesService: jasmine.Spy;
+
+ configureTestBed({
+ imports: [SharedModule, HttpClientTestingModule],
+ declarations: [
+ HealthComponent,
+ HealthPieComponent,
+ MonSummaryPipe,
+ OsdSummaryPipe,
+ MdsSummaryPipe,
+ MgrSummaryPipe
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ { provide: AuthStorageService, useValue: fakeAuthStorageService },
+ PgCategoryService,
+ RefreshIntervalService,
+ CssHelper
+ ]
+ });
+
+ beforeEach(() => {
+ fakeFeatureTogglesService = spyOn(TestBed.inject(FeatureTogglesService), 'get').and.returnValue(
+ of({
+ rbd: true,
+ mirroring: true,
+ iscsi: true,
+ cephfs: true,
+ rgw: true
+ })
+ );
+ fixture = TestBed.createComponent(HealthComponent);
+ component = fixture.componentInstance;
+ getHealthSpy = spyOn(TestBed.inject(HealthService), 'getMinimalHealth');
+ getHealthSpy.and.returnValue(of(healthPayload));
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render all info groups and all info cards', () => {
+ fixture.detectChanges();
+
+ const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
+ expect(infoGroups.length).toBe(3);
+
+ const infoCards = fixture.debugElement.nativeElement.querySelectorAll('cd-info-card');
+ expect(infoCards.length).toBe(17);
+ });
+
+ describe('features disabled', () => {
+ beforeEach(() => {
+ fakeFeatureTogglesService.and.returnValue(
+ of({
+ rbd: false,
+ mirroring: false,
+ iscsi: false,
+ cephfs: false,
+ rgw: false
+ })
+ );
+ fixture = TestBed.createComponent(HealthComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should not render cards related to disabled features', () => {
+ fixture.detectChanges();
+
+ const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
+ expect(infoGroups.length).toBe(3);
+
+ const infoCards = fixture.debugElement.nativeElement.querySelectorAll('cd-info-card');
+ expect(infoCards.length).toBe(14);
+ });
+ });
+
+ it('should render all except "Status" group and cards', () => {
+ const payload = _.cloneDeep(healthPayload);
+ payload.health.status = '';
+ payload.mon_status = null;
+ payload.osd_map = null;
+ payload.mgr_map = null;
+ payload.hosts = null;
+ payload.rgw = null;
+ payload.fs_map = null;
+ payload.iscsi_daemons = null;
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+
+ const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
+ expect(infoGroups.length).toBe(2);
+
+ const infoCards = fixture.debugElement.nativeElement.querySelectorAll('cd-info-card');
+ expect(infoCards.length).toBe(9);
+ });
+
+ it('should render all except "Performance" group and cards', () => {
+ const payload = _.cloneDeep(healthPayload);
+ payload.scrub_status = '';
+ payload.client_perf = null;
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+
+ const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
+ expect(infoGroups.length).toBe(2);
+
+ const infoCards = fixture.debugElement.nativeElement.querySelectorAll('cd-info-card');
+ expect(infoCards.length).toBe(13);
+ });
+
+ it('should render all except "Capacity" group and cards', () => {
+ const payload = _.cloneDeep(healthPayload);
+ payload.pools = null;
+ payload.df = null;
+ payload.pg_info = null;
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+
+ const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
+ expect(infoGroups.length).toBe(2);
+
+ const infoCards = fixture.debugElement.nativeElement.querySelectorAll('cd-info-card');
+ expect(infoCards.length).toBe(12);
+ });
+
+ it('should render all groups and 1 card per group', () => {
+ const payload: Record<string, any> = { hosts: 0, scrub_status: 'Inactive', pools: [] };
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+
+ const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
+ expect(infoGroups.length).toBe(3);
+
+ _.each(infoGroups, (infoGroup) => {
+ expect(infoGroup.querySelectorAll('cd-info-card').length).toBe(1);
+ });
+ });
+
+ it('should render "Cluster Status" card text that is not clickable', () => {
+ fixture.detectChanges();
+
+ const clusterStatusCard = fixture.debugElement.query(
+ By.css('cd-info-card[cardTitle="Cluster Status"]')
+ );
+ const clickableContent = clusterStatusCard.query(By.css('.info-card-content-clickable'));
+ expect(clickableContent).toBeNull();
+ expect(clusterStatusCard.nativeElement.textContent).toEqual(` ${healthPayload.health.status} `);
+ });
+
+ it('should render "Cluster Status" card text that is clickable (popover)', () => {
+ const payload = _.cloneDeep(healthPayload);
+ payload.health['status'] = 'HEALTH_WARN';
+ payload.health['checks'] = [
+ { severity: 'HEALTH_WARN', type: 'WRN', summary: { message: 'fake warning' } }
+ ];
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+
+ expect(component.permissions.log.read).toBeTruthy();
+
+ const clusterStatusCard = fixture.debugElement.query(
+ By.css('cd-info-card[cardTitle="Cluster Status"]')
+ );
+ const clickableContent = clusterStatusCard.query(By.css('.info-card-content-clickable'));
+ expect(clickableContent.nativeElement.textContent).toEqual(` ${payload.health.status} `);
+ });
+
+ it('event binding "prepareReadWriteRatio" is called', () => {
+ const prepareReadWriteRatio = spyOn(component, 'prepareReadWriteRatio').and.callThrough();
+
+ const payload = _.cloneDeep(healthPayload);
+ payload.client_perf['read_op_per_sec'] = 1;
+ payload.client_perf['write_op_per_sec'] = 3;
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+
+ expect(prepareReadWriteRatio).toHaveBeenCalled();
+ expect(prepareReadWriteRatio.calls.mostRecent().args[0].dataset[0].data).toEqual([25, 75]);
+ });
+
+ it('event binding "prepareRawUsage" is called', () => {
+ const prepareRawUsage = spyOn(component, 'prepareRawUsage');
+
+ fixture.detectChanges();
+
+ expect(prepareRawUsage).toHaveBeenCalled();
+ });
+
+ it('event binding "preparePgStatus" is called', () => {
+ const preparePgStatus = spyOn(component, 'preparePgStatus');
+
+ fixture.detectChanges();
+
+ expect(preparePgStatus).toHaveBeenCalled();
+ });
+
+ it('event binding "prepareObjects" is called', () => {
+ const prepareObjects = spyOn(component, 'prepareObjects');
+
+ fixture.detectChanges();
+
+ expect(prepareObjects).toHaveBeenCalled();
+ });
+
+ describe('preparePgStatus', () => {
+ const expectedChart = (data: number[], label: string = null) => ({
+ labels: [
+ `Clean: ${component['dimless'].transform(data[0])}`,
+ `Working: ${component['dimless'].transform(data[1])}`,
+ `Warning: ${component['dimless'].transform(data[2])}`,
+ `Unknown: ${component['dimless'].transform(data[3])}`
+ ],
+ options: {},
+ dataset: [
+ {
+ data: data.map((i) =>
+ component['calcPercentage'](
+ i,
+ data.reduce((j, k) => j + k)
+ )
+ ),
+ label: label
+ }
+ ]
+ });
+
+ it('gets no data', () => {
+ const chart = { dataset: [{}], options: {} };
+ component.preparePgStatus(chart, {
+ pg_info: {}
+ });
+ expect(chart).toEqual(expectedChart([0, 0, 0, 0], '0\nPGs'));
+ });
+
+ it('gets data from all categories', () => {
+ const chart = { dataset: [{}], options: {} };
+ component.preparePgStatus(chart, {
+ pg_info: {
+ statuses: {
+ 'clean+active+scrubbing+nonMappedState': 4,
+ 'clean+active+scrubbing': 2,
+ 'clean+active': 1,
+ 'clean+active+scrubbing+down': 3
+ }
+ }
+ });
+ expect(chart).toEqual(expectedChart([1, 2, 3, 4], '10\nPGs'));
+ });
+ });
+
+ describe('isClientReadWriteChartShowable', () => {
+ beforeEach(() => {
+ component.healthData = healthPayload;
+ });
+
+ it('returns false', () => {
+ component.healthData['client_perf'] = {};
+
+ expect(component.isClientReadWriteChartShowable()).toBeFalsy();
+ });
+
+ it('returns false', () => {
+ component.healthData['client_perf'] = { read_op_per_sec: undefined, write_op_per_sec: 0 };
+
+ expect(component.isClientReadWriteChartShowable()).toBeFalsy();
+ });
+
+ it('returns true', () => {
+ component.healthData['client_perf'] = { read_op_per_sec: 1, write_op_per_sec: undefined };
+
+ expect(component.isClientReadWriteChartShowable()).toBeTruthy();
+ });
+
+ it('returns true', () => {
+ component.healthData['client_perf'] = { read_op_per_sec: 2, write_op_per_sec: 3 };
+
+ expect(component.isClientReadWriteChartShowable()).toBeTruthy();
+ });
+ });
+
+ describe('calcPercentage', () => {
+ it('returns correct value', () => {
+ expect(component['calcPercentage'](1, undefined)).toEqual(0);
+ expect(component['calcPercentage'](1, null)).toEqual(0);
+ expect(component['calcPercentage'](1, 0)).toEqual(0);
+ expect(component['calcPercentage'](undefined, 1)).toEqual(0);
+ expect(component['calcPercentage'](null, 1)).toEqual(0);
+ expect(component['calcPercentage'](0, 1)).toEqual(0);
+ expect(component['calcPercentage'](1, 100000)).toEqual(0.01);
+ expect(component['calcPercentage'](2.346, 10)).toEqual(23.46);
+ expect(component['calcPercentage'](2.56, 10)).toEqual(25.6);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts
new file mode 100644
index 000000000..4d1dac769
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts
@@ -0,0 +1,278 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import _ from 'lodash';
+import { Subscription } from 'rxjs';
+import { take } from 'rxjs/operators';
+
+import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
+import { HealthService } from '~/app/shared/api/health.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { OsdSettings } from '~/app/shared/models/osd-settings';
+import { Permissions } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import {
+ FeatureTogglesMap$,
+ FeatureTogglesService
+} from '~/app/shared/services/feature-toggles.service';
+import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
+
+@Component({
+ selector: 'cd-health',
+ templateUrl: './health.component.html',
+ styleUrls: ['./health.component.scss']
+})
+export class HealthComponent implements OnInit, OnDestroy {
+ healthData: any;
+ osdSettings = new OsdSettings();
+ interval = new Subscription();
+ permissions: Permissions;
+ enabledFeature$: FeatureTogglesMap$;
+ icons = Icons;
+ color: string;
+
+ clientStatsConfig = {
+ colors: [
+ {
+ backgroundColor: [
+ this.cssHelper.propertyValue('chart-color-cyan'),
+ this.cssHelper.propertyValue('chart-color-purple')
+ ]
+ }
+ ]
+ };
+
+ rawCapacityChartConfig = {
+ colors: [
+ {
+ backgroundColor: [
+ this.cssHelper.propertyValue('chart-color-blue'),
+ this.cssHelper.propertyValue('chart-color-gray')
+ ]
+ }
+ ]
+ };
+
+ pgStatusChartConfig = {
+ options: {
+ events: ['']
+ }
+ };
+
+ constructor(
+ private healthService: HealthService,
+ private osdService: OsdService,
+ private authStorageService: AuthStorageService,
+ private pgCategoryService: PgCategoryService,
+ private featureToggles: FeatureTogglesService,
+ private refreshIntervalService: RefreshIntervalService,
+ private dimlessBinary: DimlessBinaryPipe,
+ private dimless: DimlessPipe,
+ private cssHelper: CssHelper
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ this.enabledFeature$ = this.featureToggles.get();
+ }
+
+ ngOnInit() {
+ this.interval = this.refreshIntervalService.intervalData$.subscribe(() => {
+ this.getHealth();
+ });
+
+ this.osdService
+ .getOsdSettings()
+ .pipe(take(1))
+ .subscribe((data: any) => {
+ this.osdSettings = data;
+ });
+ }
+
+ ngOnDestroy() {
+ this.interval.unsubscribe();
+ }
+
+ getHealth() {
+ this.healthService.getMinimalHealth().subscribe((data: any) => {
+ this.healthData = data;
+ });
+ }
+
+ prepareReadWriteRatio(chart: Record<string, any>) {
+ const ratioLabels = [];
+ const ratioData = [];
+
+ const total =
+ this.healthData.client_perf.write_op_per_sec + this.healthData.client_perf.read_op_per_sec;
+
+ ratioLabels.push(
+ `${$localize`Reads`}: ${this.dimless.transform(
+ this.healthData.client_perf.read_op_per_sec
+ )} ${$localize`/s`}`
+ );
+ ratioData.push(this.calcPercentage(this.healthData.client_perf.read_op_per_sec, total));
+ ratioLabels.push(
+ `${$localize`Writes`}: ${this.dimless.transform(
+ this.healthData.client_perf.write_op_per_sec
+ )} ${$localize`/s`}`
+ );
+ ratioData.push(this.calcPercentage(this.healthData.client_perf.write_op_per_sec, total));
+
+ chart.labels = ratioLabels;
+ chart.dataset[0].data = ratioData;
+ chart.dataset[0].label = `${this.dimless.transform(total)}\n${$localize`IOPS`}`;
+ }
+
+ prepareClientThroughput(chart: Record<string, any>) {
+ const ratioLabels = [];
+ const ratioData = [];
+
+ const total =
+ this.healthData.client_perf.read_bytes_sec + this.healthData.client_perf.write_bytes_sec;
+
+ ratioLabels.push(
+ `${$localize`Reads`}: ${this.dimlessBinary.transform(
+ this.healthData.client_perf.read_bytes_sec
+ )}${$localize`/s`}`
+ );
+ ratioData.push(this.calcPercentage(this.healthData.client_perf.read_bytes_sec, total));
+ ratioLabels.push(
+ `${$localize`Writes`}: ${this.dimlessBinary.transform(
+ this.healthData.client_perf.write_bytes_sec
+ )}${$localize`/s`}`
+ );
+ ratioData.push(this.calcPercentage(this.healthData.client_perf.write_bytes_sec, total));
+
+ chart.labels = ratioLabels;
+ chart.dataset[0].data = ratioData;
+ chart.dataset[0].label = `${this.dimlessBinary
+ .transform(total)
+ .replace(' ', '\n')}${$localize`/s`}`;
+ }
+
+ prepareRawUsage(chart: Record<string, any>, data: Record<string, any>) {
+ const percentAvailable = this.calcPercentage(
+ data.df.stats.total_bytes - data.df.stats.total_used_raw_bytes,
+ data.df.stats.total_bytes
+ );
+ const percentUsed = this.calcPercentage(
+ data.df.stats.total_used_raw_bytes,
+ data.df.stats.total_bytes
+ );
+
+ if (percentUsed / 100 >= this.osdSettings.nearfull_ratio) {
+ this.color = 'chart-color-red';
+ } else if (percentUsed / 100 >= this.osdSettings.full_ratio) {
+ this.color = 'chart-color-yellow';
+ } else {
+ this.color = 'chart-color-blue';
+ }
+ this.rawCapacityChartConfig.colors[0].backgroundColor[0] = this.cssHelper.propertyValue(
+ this.color
+ );
+
+ chart.dataset[0].data = [percentUsed, percentAvailable];
+
+ chart.labels = [
+ `${$localize`Used`}: ${this.dimlessBinary.transform(data.df.stats.total_used_raw_bytes)}`,
+ `${$localize`Avail.`}: ${this.dimlessBinary.transform(
+ data.df.stats.total_bytes - data.df.stats.total_used_raw_bytes
+ )}`
+ ];
+
+ chart.dataset[0].label = `${percentUsed}%\nof ${this.dimlessBinary.transform(
+ data.df.stats.total_bytes
+ )}`;
+ }
+
+ preparePgStatus(chart: Record<string, any>, data: Record<string, any>) {
+ const categoryPgAmount: Record<string, number> = {};
+ let totalPgs = 0;
+
+ _.forEach(data.pg_info.statuses, (pgAmount, pgStatesText) => {
+ const categoryType = this.pgCategoryService.getTypeByStates(pgStatesText);
+
+ if (_.isUndefined(categoryPgAmount[categoryType])) {
+ categoryPgAmount[categoryType] = 0;
+ }
+ categoryPgAmount[categoryType] += pgAmount;
+ totalPgs += pgAmount;
+ });
+
+ for (const categoryType of this.pgCategoryService.getAllTypes()) {
+ if (_.isUndefined(categoryPgAmount[categoryType])) {
+ categoryPgAmount[categoryType] = 0;
+ }
+ }
+
+ chart.dataset[0].data = this.pgCategoryService
+ .getAllTypes()
+ .map((categoryType) => this.calcPercentage(categoryPgAmount[categoryType], totalPgs));
+
+ chart.labels = [
+ `${$localize`Clean`}: ${this.dimless.transform(categoryPgAmount['clean'])}`,
+ `${$localize`Working`}: ${this.dimless.transform(categoryPgAmount['working'])}`,
+ `${$localize`Warning`}: ${this.dimless.transform(categoryPgAmount['warning'])}`,
+ `${$localize`Unknown`}: ${this.dimless.transform(categoryPgAmount['unknown'])}`
+ ];
+
+ chart.dataset[0].label = `${totalPgs}\n${$localize`PGs`}`;
+ }
+
+ prepareObjects(chart: Record<string, any>, data: Record<string, any>) {
+ const objectCopies = data.pg_info.object_stats.num_object_copies;
+ const healthy =
+ objectCopies -
+ data.pg_info.object_stats.num_objects_misplaced -
+ data.pg_info.object_stats.num_objects_degraded -
+ data.pg_info.object_stats.num_objects_unfound;
+ const healthyPercentage = this.calcPercentage(healthy, objectCopies);
+ const misplacedPercentage = this.calcPercentage(
+ data.pg_info.object_stats.num_objects_misplaced,
+ objectCopies
+ );
+ const degradedPercentage = this.calcPercentage(
+ data.pg_info.object_stats.num_objects_degraded,
+ objectCopies
+ );
+ const unfoundPercentage = this.calcPercentage(
+ data.pg_info.object_stats.num_objects_unfound,
+ objectCopies
+ );
+
+ chart.labels = [
+ `${$localize`Healthy`}: ${healthyPercentage}%`,
+ `${$localize`Misplaced`}: ${misplacedPercentage}%`,
+ `${$localize`Degraded`}: ${degradedPercentage}%`,
+ `${$localize`Unfound`}: ${unfoundPercentage}%`
+ ];
+
+ chart.dataset[0].data = [
+ healthyPercentage,
+ misplacedPercentage,
+ degradedPercentage,
+ unfoundPercentage
+ ];
+
+ chart.dataset[0].label = `${this.dimless.transform(
+ data.pg_info.object_stats.num_objects
+ )}\n${$localize`objects`}`;
+ }
+
+ isClientReadWriteChartShowable() {
+ const readOps = this.healthData.client_perf.read_op_per_sec || 0;
+ const writeOps = this.healthData.client_perf.write_op_per_sec || 0;
+
+ return readOps + writeOps > 0;
+ }
+
+ private calcPercentage(dividend: number, divisor: number) {
+ if (!_.isNumber(dividend) || !_.isNumber(divisor) || divisor === 0) {
+ return 0;
+ }
+
+ return Math.ceil((dividend / divisor) * 100 * 100) / 100;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card-popover.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card-popover.scss
new file mode 100644
index 000000000..43cbe18ff
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card-popover.scss
@@ -0,0 +1,42 @@
+@use './src/styles/vendor/variables' as vv;
+
+.info-card-popover-cluster-status {
+ max-height: 20vh;
+ max-width: 23vw;
+
+ .popover-body {
+ font-size: 1rem;
+ max-height: 19vh;
+ max-width: 100%;
+ overflow: auto;
+ }
+}
+
+@media (max-width: vv.$screen-lg-max) {
+ .info-card-popover-cluster-status {
+ max-width: 31vw;
+ }
+}
+
+@media (max-width: vv.$screen-md-max) {
+ .info-card-popover-cluster-status {
+ max-width: 46vw;
+ }
+}
+@media (max-width: vv.$screen-sm-max) {
+ .info-card-popover-cluster-status {
+ max-width: 83vw;
+ }
+}
+
+.info-card-content-clickable {
+ border: 1px solid vv.$gray-200;
+ border-radius: 3px;
+ cursor: pointer;
+ padding: 7px;
+}
+
+.info-card-content-clickable:hover {
+ background-color: vv.$gray-200;
+ border-color: vv.$gray-400;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.html
new file mode 100644
index 000000000..ef0328502
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.html
@@ -0,0 +1,18 @@
+<div class="card shadow-sm"
+ [ngClass]="cardClass">
+ <div class="card-body d-flex align-items-center justify-content-center">
+ <h4 class="card-title m-4">
+ <a *ngIf="link; else noLinkTitle"
+ [routerLink]="link">{{ cardTitle }}</a>
+
+ <ng-template #noLinkTitle>
+ {{ cardTitle }}
+ </ng-template>
+ </h4>
+
+ <div class="card-text text-center"
+ [ngClass]="contentClass">
+ <ng-content></ng-content>
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.scss
new file mode 100644
index 000000000..897d09a9a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.scss
@@ -0,0 +1,40 @@
+@use './src/styles/vendor/variables' as vv;
+@use './src/styles/defaults/mixins';
+
+$card-font-min-width: 320px;
+$card-font-max-width: 2048px;
+$card-font-min-size: 12px;
+$card-font-max-size: 21px;
+
+.card {
+ @include mixins.fluid-font-size(
+ $card-font-min-width,
+ $card-font-max-width,
+ $card-font-min-size,
+ $card-font-max-size
+ );
+ border: 0.5px solid vv.$gray-300;
+ border-radius: 3px;
+ height: 100%;
+
+ .card-body {
+ padding-top: 40px !important;
+
+ .card-title {
+ left: -0.6rem;
+ position: absolute;
+ top: -0.3rem;
+ }
+ }
+}
+
+.no-center {
+ left: unset;
+ position: unset;
+ top: unset;
+ transform: unset;
+}
+
+.content-highlight {
+ font-weight: bold;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.spec.ts
new file mode 100644
index 000000000..bde9a9a00
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.spec.ts
@@ -0,0 +1,65 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { InfoCardComponent } from './info-card.component';
+
+describe('InfoCardComponent', () => {
+ let component: InfoCardComponent;
+ let fixture: ComponentFixture<InfoCardComponent>;
+
+ configureTestBed({
+ imports: [RouterTestingModule],
+ declarations: [InfoCardComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(InfoCardComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('Setting cardTitle makes title visible', () => {
+ const cardTitle = 'Card Title';
+ component.cardTitle = cardTitle;
+ fixture.detectChanges();
+ const titleDiv = fixture.debugElement.nativeElement.querySelector('.card-title');
+
+ expect(titleDiv.textContent).toContain(cardTitle);
+ });
+
+ it('Setting link makes anchor visible', () => {
+ const cardTitle = 'Card Title';
+ const link = '/dashboard';
+ component.cardTitle = cardTitle;
+ component.link = link;
+ fixture.detectChanges();
+ const anchor = fixture.debugElement.nativeElement
+ .querySelector('.card-title')
+ .querySelector('a');
+
+ expect(anchor.textContent).toContain(cardTitle);
+ expect(anchor.href).toBe(`http://localhost${link}`);
+ });
+
+ it('Setting cardClass makes class set', () => {
+ const cardClass = 'my-css-card-class';
+ component.cardClass = cardClass;
+ fixture.detectChanges();
+ const card = fixture.debugElement.nativeElement.querySelector(`.card.${cardClass}`);
+
+ expect(card).toBeTruthy();
+ });
+
+ it('Setting contentClass makes class set', () => {
+ const contentClass = 'my-css-content-class';
+ component.contentClass = contentClass;
+ fixture.detectChanges();
+ const card = fixture.debugElement.nativeElement.querySelector(`.card-body .${contentClass}`);
+
+ expect(card).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.ts
new file mode 100644
index 000000000..fdcbe2ece
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-card/info-card.component.ts
@@ -0,0 +1,17 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'cd-info-card',
+ templateUrl: './info-card.component.html',
+ styleUrls: ['./info-card.component.scss']
+})
+export class InfoCardComponent {
+ @Input()
+ cardTitle: string;
+ @Input()
+ link: string;
+ @Input()
+ cardClass = '';
+ @Input()
+ contentClass: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.html
new file mode 100644
index 000000000..722824a8f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.html
@@ -0,0 +1,26 @@
+<div class="row">
+ <div class="info-group-title"
+ [ngbPopover]="popInfoTemplate"
+ #popInfo="ngbPopover"
+ triggers="">
+ <span>{{ groupTitle }}</span>
+ <button type="button"
+ class="popover-icon btn btn-link p-0"
+ (click)="popInfo.toggle()">
+ <i [ngClass]="[icons.infoCircle, icons.large]"></i>
+ </button>
+ </div>
+</div>
+
+<div class="row">
+ <ng-content></ng-content>
+</div>
+
+<ng-template #popInfoTemplate>
+ <div class="text-center"
+ i18n>For an overview of {{ groupTitle|lowercase }} widgets click
+ <cd-doc section="dashboard-landing-page-{{ groupTitle|lowercase }}"
+ docText="here"
+ i18n-docText></cd-doc>
+ </div>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.scss
new file mode 100644
index 000000000..52bcddb96
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.scss
@@ -0,0 +1,8 @@
+.info-group-title {
+ font-size: 1.75rem;
+ margin: 0 0 0.5vw 0.5vw;
+}
+
+.popover-icon:focus {
+ box-shadow: none;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.spec.ts
new file mode 100644
index 000000000..73ed55a8f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.spec.ts
@@ -0,0 +1,35 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { InfoGroupComponent } from './info-group.component';
+
+describe('InfoGroupComponent', () => {
+ let component: InfoGroupComponent;
+ let fixture: ComponentFixture<InfoGroupComponent>;
+
+ configureTestBed({
+ imports: [NgbPopoverModule, SharedModule],
+ declarations: [InfoGroupComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(InfoGroupComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('Setting groupTitle makes title visible', () => {
+ const groupTitle = 'Group Title';
+ component.groupTitle = groupTitle;
+ fixture.detectChanges();
+ const titleDiv = fixture.debugElement.nativeElement.querySelector('.info-group-title');
+
+ expect(titleDiv.textContent).toContain(groupTitle);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.ts
new file mode 100644
index 000000000..167db9e2f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/info-group/info-group.component.ts
@@ -0,0 +1,14 @@
+import { Component, Input } from '@angular/core';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-info-group',
+ templateUrl: './info-group.component.html',
+ styleUrls: ['./info-group.component.scss']
+})
+export class InfoGroupComponent {
+ icons = Icons;
+ @Input()
+ groupTitle: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts
new file mode 100644
index 000000000..c62b35c54
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts
@@ -0,0 +1,72 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MdsSummaryPipe } from './mds-summary.pipe';
+
+describe('MdsSummaryPipe', () => {
+ let pipe: MdsSummaryPipe;
+
+ configureTestBed({
+ providers: [MdsSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(MdsSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms with 0 active and 2 standy', () => {
+ const payload = {
+ standbys: [{ name: 'a' }],
+ filesystems: [{ mdsmap: { info: [{ state: 'up:standby-replay' }] } }]
+ };
+ const expected = [
+ { class: 'popover-info', content: '0 active', titleText: '1 standbyReplay' },
+ { class: 'card-text-line-break', content: '', titleText: '' },
+ { class: 'popover-info', content: '2 standby', titleText: 'standby daemons: a' }
+ ];
+
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+
+ it('transforms with 1 active and 1 standy', () => {
+ const payload = {
+ standbys: [{ name: 'b' }],
+ filesystems: [{ mdsmap: { info: [{ state: 'up:active', name: 'a' }] } }]
+ };
+ const expected = [
+ { class: 'popover-info', content: '1 active', titleText: 'active daemon: a' },
+ { class: 'card-text-line-break', content: '', titleText: '' },
+ { class: 'popover-info', content: '1 standby', titleText: 'standby daemons: b' }
+ ];
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+
+ it('transforms with 0 filesystems', () => {
+ const payload: Record<string, any> = {
+ standbys: [0],
+ filesystems: []
+ };
+ const expected = [{ class: 'popover-info', content: 'no filesystems', titleText: '' }];
+
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+
+ it('transforms without filesystem', () => {
+ const payload = { standbys: [{ name: 'a' }] };
+ const expected = [
+ { class: 'popover-info', content: '1 up', titleText: '' },
+ { class: 'card-text-line-break', content: '', titleText: '' },
+ { class: 'popover-info', content: 'no filesystems', titleText: 'standby daemons: a' }
+ ];
+
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toBe('');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts
new file mode 100644
index 000000000..9cc72ac96
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts
@@ -0,0 +1,78 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'mdsSummary'
+})
+export class MdsSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return '';
+ }
+
+ let contentLine1 = '';
+ let contentLine2 = '';
+ let standbys = 0;
+ let active = 0;
+ let standbyReplay = 0;
+ _.each(value.standbys, () => {
+ standbys += 1;
+ });
+
+ if (value.standbys && !value.filesystems) {
+ contentLine1 = `${standbys} ${$localize`up`}`;
+ contentLine2 = $localize`no filesystems`;
+ } else if (value.filesystems.length === 0) {
+ contentLine1 = $localize`no filesystems`;
+ } else {
+ _.each(value.filesystems, (fs) => {
+ _.each(fs.mdsmap.info, (mds) => {
+ if (mds.state === 'up:standby-replay') {
+ standbyReplay += 1;
+ } else {
+ active += 1;
+ }
+ });
+ });
+
+ contentLine1 = `${active} ${$localize`active`}`;
+ contentLine2 = `${standbys + standbyReplay} ${$localize`standby`}`;
+ }
+ const standbyHoverText = value.standbys.map((s: any): string => s.name).join(', ');
+ const standbyTitleText = !standbyHoverText
+ ? ''
+ : `${$localize`standby daemons`}: ${standbyHoverText}`;
+ const fsLength = value.filesystems ? value.filesystems.length : 0;
+ const infoObject = fsLength > 0 ? value.filesystems[0].mdsmap.info : {};
+ const activeHoverText = Object.values(infoObject)
+ .map((info: any): string => info.name)
+ .join(', ');
+ let activeTitleText = !activeHoverText ? '' : `${$localize`active daemon`}: ${activeHoverText}`;
+ // There is always one standbyreplay to replace active daemon, if active one is down
+ if (!active && fsLength > 0) {
+ activeTitleText = `${standbyReplay} ${$localize`standbyReplay`}`;
+ }
+ const mgrSummary = [
+ {
+ content: contentLine1,
+ class: 'popover-info',
+ titleText: activeTitleText
+ }
+ ];
+ if (contentLine2) {
+ mgrSummary.push({
+ content: '',
+ class: 'card-text-line-break',
+ titleText: ''
+ });
+ mgrSummary.push({
+ content: contentLine2,
+ class: 'popover-info',
+ titleText: standbyTitleText
+ });
+ }
+
+ return mgrSummary;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts
new file mode 100644
index 000000000..8bc275380
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts
@@ -0,0 +1,52 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrSummaryPipe } from './mgr-summary.pipe';
+
+describe('MgrSummaryPipe', () => {
+ let pipe: MgrSummaryPipe;
+
+ configureTestBed({
+ providers: [MgrSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(MgrSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toBe('');
+ });
+
+ it('transforms with active_name undefined', () => {
+ const payload: Record<string, any> = {
+ active_name: undefined,
+ standbys: []
+ };
+ const expected = [
+ { class: 'popover-info', content: 'n/a active', titleText: '' },
+ { class: 'card-text-line-break', content: '', titleText: '' },
+ { class: 'popover-info', content: '0 standby', titleText: '' }
+ ];
+
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+
+ it('transforms with 1 active and 2 standbys', () => {
+ const payload = {
+ active_name: 'x',
+ standbys: [{ name: 'y' }, { name: 'z' }]
+ };
+ const expected = [
+ { class: 'popover-info', content: '1 active', titleText: 'active daemon: x' },
+ { class: 'card-text-line-break', content: '', titleText: '' },
+ { class: 'popover-info', content: '2 standby', titleText: 'standby daemons: y, z' }
+ ];
+
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts
new file mode 100644
index 000000000..ffdee7300
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts
@@ -0,0 +1,48 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'mgrSummary'
+})
+export class MgrSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return '';
+ }
+
+ let activeCount = $localize`n/a`;
+ const activeTitleText = _.isUndefined(value.active_name)
+ ? ''
+ : `${$localize`active daemon`}: ${value.active_name}`;
+ // There is always one standbyreplay to replace active daemon, if active one is down
+ if (activeTitleText.length > 0) {
+ activeCount = '1';
+ }
+ const standbyHoverText = value.standbys.map((s: any): string => s.name).join(', ');
+ const standbyTitleText = !standbyHoverText
+ ? ''
+ : `${$localize`standby daemons`}: ${standbyHoverText}`;
+ const standbyCount = value.standbys.length;
+ const mgrSummary = [
+ {
+ content: `${activeCount} ${$localize`active`}`,
+ class: 'popover-info',
+ titleText: activeTitleText
+ }
+ ];
+
+ mgrSummary.push({
+ content: '',
+ class: 'card-text-line-break',
+ titleText: ''
+ });
+ mgrSummary.push({
+ content: `${standbyCount} ${$localize`standby`}`,
+ class: 'popover-info',
+ titleText: standbyTitleText
+ });
+
+ return mgrSummary;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts
new file mode 100644
index 000000000..b8a083a32
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts
@@ -0,0 +1,40 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MonSummaryPipe } from './mon-summary.pipe';
+
+describe('MonSummaryPipe', () => {
+ let pipe: MonSummaryPipe;
+
+ configureTestBed({
+ providers: [MonSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(MonSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toBe('');
+ });
+
+ it('transforms with 3 mons in quorum', () => {
+ const value = {
+ monmap: { mons: [0, 1, 2] },
+ quorum: [0, 1, 2]
+ };
+ expect(pipe.transform(value)).toBe('3 (quorum 0, 1, 2)');
+ });
+
+ it('transforms with 2/3 mons in quorum', () => {
+ const value = {
+ monmap: { mons: [0, 1, 2] },
+ quorum: [0, 1]
+ };
+ expect(pipe.transform(value)).toBe('3 (quorum 0, 1)');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts
new file mode 100644
index 000000000..399045d5d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts
@@ -0,0 +1,17 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'monSummary'
+})
+export class MonSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return '';
+ }
+
+ const result = $localize`${value.monmap.mons.length.toString()} (quorum \
+${value.quorum.join(', ')})`;
+
+ return result;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts
new file mode 100644
index 000000000..22f5eeff3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts
@@ -0,0 +1,193 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdSummaryPipe } from './osd-summary.pipe';
+
+describe('OsdSummaryPipe', () => {
+ let pipe: OsdSummaryPipe;
+
+ configureTestBed({
+ providers: [OsdSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(OsdSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toBe('');
+ });
+
+ it('transforms having 3 osd with 3 up, 3 in, 0 down, 0 out', () => {
+ const value = {
+ osds: [
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 1, state: ['up', 'exists'] }
+ ]
+ };
+ expect(pipe.transform(value)).toEqual([
+ {
+ content: '3 total',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '3 up, 3 in',
+ class: ''
+ }
+ ]);
+ });
+
+ it('transforms having 3 osd with 2 up, 1 in, 1 down, 2 out', () => {
+ const value = {
+ osds: [
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 0, state: ['up', 'exists'] },
+ { up: 0, in: 0, state: ['exists'] }
+ ]
+ };
+ expect(pipe.transform(value)).toEqual([
+ {
+ content: '3 total',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '2 up, 1 in',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '1 down, 2 out',
+ class: 'card-text-error'
+ }
+ ]);
+ });
+
+ it('transforms having 3 osd with 2 up, 3 in, 1 full, 1 nearfull, 1 down, 0 out', () => {
+ const value = {
+ osds: [
+ { up: 1, in: 1, state: ['up', 'nearfull'] },
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 0, in: 1, state: ['full'] }
+ ]
+ };
+ expect(pipe.transform(value)).toEqual([
+ {
+ content: '3 total',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '2 up, 3 in',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '1 down',
+ class: 'card-text-error'
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '1 near full',
+ class: 'card-text-error'
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '1 full',
+ class: 'card-text-error'
+ }
+ ]);
+ });
+
+ it('transforms having 3 osd with 3 up, 2 in, 0 down, 1 out', () => {
+ const value = {
+ osds: [
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 0, state: ['up', 'exists'] }
+ ]
+ };
+ expect(pipe.transform(value)).toEqual([
+ {
+ content: '3 total',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '3 up, 2 in',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '1 out',
+ class: 'card-text-error'
+ }
+ ]);
+ });
+
+ it('transforms having 4 osd with 3 up, 2 in, 1 down, another 2 out', () => {
+ const value = {
+ osds: [
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 0, state: ['up', 'exists'] },
+ { up: 1, in: 0, state: ['up', 'exists'] },
+ { up: 0, in: 1, state: ['exists'] }
+ ]
+ };
+ expect(pipe.transform(value)).toEqual([
+ {
+ content: '4 total',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '3 up, 2 in',
+ class: ''
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: '1 down, 2 out',
+ class: 'card-text-error'
+ }
+ ]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts
new file mode 100644
index 000000000..46d2eda6b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts
@@ -0,0 +1,91 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'osdSummary'
+})
+export class OsdSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return '';
+ }
+
+ let inCount = 0;
+ let upCount = 0;
+ let nearFullCount = 0;
+ let fullCount = 0;
+ _.each(value.osds, (osd) => {
+ if (osd.in) {
+ inCount++;
+ }
+ if (osd.up) {
+ upCount++;
+ }
+ if (osd.state.includes('nearfull')) {
+ nearFullCount++;
+ }
+ if (osd.state.includes('full')) {
+ fullCount++;
+ }
+ });
+
+ const osdSummary = [
+ {
+ content: `${value.osds.length} ${$localize`total`}`,
+ class: ''
+ }
+ ];
+ osdSummary.push({
+ content: '',
+ class: 'card-text-line-break'
+ });
+ osdSummary.push({
+ content: `${upCount} ${$localize`up`}, ${inCount} ${$localize`in`}`,
+ class: ''
+ });
+
+ const downCount = value.osds.length - upCount;
+ const outCount = value.osds.length - inCount;
+ if (downCount > 0 || outCount > 0) {
+ osdSummary.push({
+ content: '',
+ class: 'card-text-line-break'
+ });
+
+ const downText = downCount > 0 ? `${downCount} ${$localize`down`}` : '';
+ const separator = downCount > 0 && outCount > 0 ? ', ' : '';
+ const outText = outCount > 0 ? `${outCount} ${$localize`out`}` : '';
+ osdSummary.push({
+ content: `${downText}${separator}${outText}`,
+ class: 'card-text-error'
+ });
+ }
+
+ if (nearFullCount > 0) {
+ osdSummary.push(
+ {
+ content: '',
+ class: 'card-text-line-break'
+ },
+ {
+ content: `${nearFullCount} ${$localize`near full`}`,
+ class: 'card-text-error'
+ },
+ {
+ content: '',
+ class: 'card-text-line-break'
+ }
+ );
+ }
+
+ if (fullCount > 0) {
+ osdSummary.push({
+ content: `${fullCount} ${$localize`full`}`,
+ class: 'card-text-error'
+ });
+ }
+
+ return osdSummary;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/models/nfs.fsal.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/models/nfs.fsal.ts
new file mode 100644
index 000000000..f204ac6d8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/models/nfs.fsal.ts
@@ -0,0 +1,5 @@
+export interface NfsFSAbstractionLayer {
+ value: string;
+ descr: string;
+ disabled: boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html
new file mode 100644
index 000000000..2a8b9453f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html
@@ -0,0 +1,32 @@
+<ng-container *ngIf="selection">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="nfs-details">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value [data]="data">
+ </cd-table-key-value>
+ </ng-template>
+ </li>
+ <li ngbNavItem="clients">
+ <a ngbNavLink
+ i18n>Clients ({{ clients.length }})</a>
+ <ng-template ngbNavContent>
+
+ <cd-table #table
+ [data]="clients"
+ columnMode="flex"
+ [columns]="clientsColumns"
+ identifier="addresses"
+ forceIdentifier="true"
+ selectionType="">
+ </cd-table>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts
new file mode 100644
index 000000000..fcf530539
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts
@@ -0,0 +1,102 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NfsDetailsComponent } from './nfs-details.component';
+
+describe('NfsDetailsComponent', () => {
+ let component: NfsDetailsComponent;
+ let fixture: ComponentFixture<NfsDetailsComponent>;
+
+ const elem = (css: string) => fixture.debugElement.query(By.css(css));
+
+ configureTestBed({
+ declarations: [NfsDetailsComponent],
+ imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule, NgbNavModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NfsDetailsComponent);
+ component = fixture.componentInstance;
+
+ component.selection = {
+ export_id: 1,
+ path: '/qwe',
+ fsal: { name: 'CEPH', user_id: 'fs', fs_name: 1 },
+ cluster_id: 'cluster1',
+ pseudo: '/qwe',
+ access_type: 'RW',
+ squash: 'no_root_squash',
+ protocols: [4],
+ transports: ['TCP', 'UDP'],
+ clients: [
+ {
+ addresses: ['192.168.0.10', '192.168.1.0/8'],
+ access_type: 'RW',
+ squash: 'root_id_squash'
+ }
+ ]
+ };
+ component.ngOnChanges();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component.data).toBeTruthy();
+ });
+
+ it('should prepare data', () => {
+ expect(component.data).toEqual({
+ 'Access Type': 'RW',
+ 'CephFS Filesystem': 1,
+ 'CephFS User': 'fs',
+ Cluster: 'cluster1',
+ 'NFS Protocol': ['NFSv4'],
+ Path: '/qwe',
+ Pseudo: '/qwe',
+ 'Security Label': undefined,
+ Squash: 'no_root_squash',
+ 'Storage Backend': 'CephFS',
+ Transport: ['TCP', 'UDP']
+ });
+ });
+
+ it('should prepare data if RGW', () => {
+ const newData = _.assignIn(component.selection, {
+ fsal: {
+ name: 'RGW',
+ user_id: 'user-id'
+ }
+ });
+ component.selection = newData;
+ component.ngOnChanges();
+ expect(component.data).toEqual({
+ 'Access Type': 'RW',
+ Cluster: 'cluster1',
+ 'NFS Protocol': ['NFSv4'],
+ 'Object Gateway User': 'user-id',
+ Path: '/qwe',
+ Pseudo: '/qwe',
+ Squash: 'no_root_squash',
+ 'Storage Backend': 'Object Gateway',
+ Transport: ['TCP', 'UDP']
+ });
+ });
+
+ it('should have 1 client', () => {
+ expect(elem('ul.nav-tabs li:nth-of-type(2) a').nativeElement.textContent).toBe('Clients (1)');
+ expect(component.clients).toEqual([
+ {
+ access_type: 'RW',
+ addresses: ['192.168.0.10', '192.168.1.0/8'],
+ squash: 'root_id_squash'
+ }
+ ]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts
new file mode 100644
index 000000000..5a84bd52e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts
@@ -0,0 +1,68 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+
+@Component({
+ selector: 'cd-nfs-details',
+ templateUrl: './nfs-details.component.html',
+ styleUrls: ['./nfs-details.component.scss']
+})
+export class NfsDetailsComponent implements OnChanges {
+ @Input()
+ selection: any;
+
+ selectedItem: any;
+ data: any;
+
+ clientsColumns: CdTableColumn[];
+ clients: any[] = [];
+
+ constructor() {
+ this.clientsColumns = [
+ {
+ name: $localize`Addresses`,
+ prop: 'addresses',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Access Type`,
+ prop: 'access_type',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Squash`,
+ prop: 'squash',
+ flexGrow: 1
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.selectedItem = this.selection;
+
+ this.clients = this.selectedItem.clients;
+
+ this.data = {};
+ this.data[$localize`Cluster`] = this.selectedItem.cluster_id;
+ this.data[$localize`NFS Protocol`] = this.selectedItem.protocols.map(
+ (protocol: string) => 'NFSv' + protocol
+ );
+ this.data[$localize`Pseudo`] = this.selectedItem.pseudo;
+ this.data[$localize`Access Type`] = this.selectedItem.access_type;
+ this.data[$localize`Squash`] = this.selectedItem.squash;
+ this.data[$localize`Transport`] = this.selectedItem.transports;
+ this.data[$localize`Path`] = this.selectedItem.path;
+
+ if (this.selectedItem.fsal.name === 'CEPH') {
+ this.data[$localize`Storage Backend`] = $localize`CephFS`;
+ this.data[$localize`CephFS User`] = this.selectedItem.fsal.user_id;
+ this.data[$localize`CephFS Filesystem`] = this.selectedItem.fsal.fs_name;
+ this.data[$localize`Security Label`] = this.selectedItem.fsal.sec_label_xattr;
+ } else {
+ this.data[$localize`Storage Backend`] = $localize`Object Gateway`;
+ this.data[$localize`Object Gateway User`] = this.selectedItem.fsal.user_id;
+ }
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.html
new file mode 100644
index 000000000..117ad371d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.html
@@ -0,0 +1,109 @@
+<div class="form-group row">
+ <label class="cd-col-form-label"
+ i18n>Clients</label>
+
+ <div class="cd-col-form-input"
+ [formGroup]="form"
+ #formDir="ngForm">
+ <span *ngIf="form.get('clients').value.length === 0"
+ class="no-border text-muted">
+ <span class="form-text text-muted"
+ i18n>Any client can access</span>
+ </span>
+
+ <ng-container formArrayName="clients">
+ <div *ngFor="let item of clientsFormArray.controls; let index = index; trackBy: trackByFn">
+ <div class="card"
+ [formGroup]="item">
+ <div class="card-header">
+ {{ (index + 1) | ordinal }}
+ <span class="float-right clickable"
+ name="remove_client"
+ (click)="removeClient(index)"
+ ngbTooltip="Remove">&times;</span>
+ </div>
+
+ <div class="card-body">
+ <!-- Addresses -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label required"
+ for="addresses">Addresses</label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ class="form-control"
+ name="addresses"
+ id="addresses"
+ formControlName="addresses"
+ placeholder="192.168.0.10, 192.168.1.0/8">
+ <span class="invalid-feedback">
+ <span *ngIf="showError(index, 'addresses', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span *ngIf="showError(index, 'addresses', formDir, 'pattern')">
+ <ng-container i18n>Must contain one or more comma-separated values</ng-container>
+ <br>
+ <ng-container i18n>For example:</ng-container> 192.168.0.10, 192.168.1.0/8
+ </span>
+ </span>
+ </div>
+ </div>
+
+ <!-- Access Type-->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="access_type">Access Type</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ name="access_type"
+ id="access_type"
+ formControlName="access_type">
+ <option value="">{{ getNoAccessTypeDescr() }}</option>
+ <option *ngFor="let item of nfsAccessType"
+ [value]="item.value">{{ item.value }}</option>
+ </select>
+ <span class="form-text text-muted"
+ *ngIf="getValue(index, 'access_type')">
+ {{ getAccessTypeHelp(index) }}
+ </span>
+ </div>
+ </div>
+
+ <!-- Squash -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="squash">
+ <span i18n>Squash</span>
+ <ng-container *ngTemplateOutlet="squashHelperTpl"></ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ name="squash"
+ id="squash"
+ formControlName="squash">
+ <option value="">{{ getNoSquashDescr() }}</option>
+ <option *ngFor="let squash of nfsSquash"
+ [value]="squash">{{ squash }}</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </ng-container>
+
+ <div class="row">
+ <div class="col-12">
+ <div class="float-right">
+ <button class="btn btn-light "
+ (click)="addClient()"
+ name="add_client">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add clients</ng-container>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.spec.ts
new file mode 100644
index 000000000..70d885d84
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.spec.ts
@@ -0,0 +1,71 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NfsFormClientComponent } from './nfs-form-client.component';
+
+describe('NfsFormClientComponent', () => {
+ let component: NfsFormClientComponent;
+ let fixture: ComponentFixture<NfsFormClientComponent>;
+
+ configureTestBed({
+ declarations: [NfsFormClientComponent],
+ imports: [ReactiveFormsModule, SharedModule, HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NfsFormClientComponent);
+ const formBuilder = TestBed.inject(CdFormBuilder);
+ component = fixture.componentInstance;
+
+ component.form = new CdFormGroup({
+ access_type: new FormControl(''),
+ clients: formBuilder.array([]),
+ squash: new FormControl('')
+ });
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should add a client', () => {
+ expect(component.form.getValue('clients')).toEqual([]);
+ component.addClient();
+ expect(component.form.getValue('clients')).toEqual([
+ { access_type: '', addresses: '', squash: '' }
+ ]);
+ });
+
+ it('should return form access_type', () => {
+ expect(component.getNoAccessTypeDescr()).toBe('-- Select the access type --');
+
+ component.form.patchValue({ access_type: 'RW' });
+ expect(component.getNoAccessTypeDescr()).toBe('RW (inherited from global config)');
+ });
+
+ it('should return form squash', () => {
+ expect(component.getNoSquashDescr()).toBe(
+ '-- Select what kind of user id squashing is performed --'
+ );
+
+ component.form.patchValue({ squash: 'root_id_squash' });
+ expect(component.getNoSquashDescr()).toBe('root_id_squash (inherited from global config)');
+ });
+
+ it('should remove client', () => {
+ component.addClient();
+ expect(component.form.getValue('clients')).toEqual([
+ { access_type: '', addresses: '', squash: '' }
+ ]);
+
+ component.removeClient(0);
+ expect(component.form.getValue('clients')).toEqual([]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.ts
new file mode 100644
index 000000000..15e7d7d5c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form-client/nfs-form-client.component.ts
@@ -0,0 +1,95 @@
+import { Component, ContentChild, Input, OnInit, TemplateRef } from '@angular/core';
+import { FormArray, FormControl, NgForm, Validators } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { NfsService } from '~/app/shared/api/nfs.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+@Component({
+ selector: 'cd-nfs-form-client',
+ templateUrl: './nfs-form-client.component.html',
+ styleUrls: ['./nfs-form-client.component.scss']
+})
+export class NfsFormClientComponent implements OnInit {
+ @Input()
+ form: CdFormGroup;
+
+ @Input()
+ clients: any[];
+
+ @ContentChild('squashHelper', { static: true }) squashHelperTpl: TemplateRef<any>;
+
+ nfsSquash: any[] = Object.keys(this.nfsService.nfsSquash);
+ nfsAccessType: any[] = this.nfsService.nfsAccessType;
+ icons = Icons;
+ clientsFormArray: FormArray;
+
+ constructor(private nfsService: NfsService) {}
+
+ ngOnInit() {
+ _.forEach(this.clients, (client) => {
+ const fg = this.addClient();
+ fg.patchValue(client);
+ });
+ this.clientsFormArray = this.form.get('clients') as FormArray;
+ }
+
+ getNoAccessTypeDescr() {
+ if (this.form.getValue('access_type')) {
+ return `${this.form.getValue('access_type')} ${$localize`(inherited from global config)`}`;
+ }
+ return $localize`-- Select the access type --`;
+ }
+
+ getAccessTypeHelp(index: number) {
+ const accessTypeItem = this.nfsAccessType.find((currentAccessTypeItem) => {
+ return this.getValue(index, 'access_type') === currentAccessTypeItem.value;
+ });
+ return _.isObjectLike(accessTypeItem) ? accessTypeItem.help : '';
+ }
+
+ getNoSquashDescr() {
+ if (this.form.getValue('squash')) {
+ return `${this.form.getValue('squash')} (${$localize`inherited from global config`})`;
+ }
+ return $localize`-- Select what kind of user id squashing is performed --`;
+ }
+
+ addClient() {
+ this.clientsFormArray = this.form.get('clients') as FormArray;
+
+ const REGEX_IP = `(([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\.([0-9]{1,3})([/](\\d|[1-2]\\d|3[0-2]))?)`;
+ const REGEX_LIST_IP = `${REGEX_IP}([ ,]{1,2}${REGEX_IP})*`;
+ const fg = new CdFormGroup({
+ addresses: new FormControl('', {
+ validators: [Validators.required, Validators.pattern(REGEX_LIST_IP)]
+ }),
+ access_type: new FormControl(''),
+ squash: new FormControl('')
+ });
+
+ this.clientsFormArray.push(fg);
+ return fg;
+ }
+
+ removeClient(index: number) {
+ this.clientsFormArray = this.form.get('clients') as FormArray;
+ this.clientsFormArray.removeAt(index);
+ }
+
+ showError(index: number, control: string, formDir: NgForm, x: string) {
+ return (<any>this.form.controls.clients).controls[index].showError(control, formDir, x);
+ }
+
+ getValue(index: number, control: string) {
+ this.clientsFormArray = this.form.get('clients') as FormArray;
+ const client = this.clientsFormArray.at(index) as CdFormGroup;
+ return client.getValue(control);
+ }
+
+ trackByFn(index: number) {
+ return index;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html
new file mode 100644
index 000000000..7313ea69b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.html
@@ -0,0 +1,400 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="nfsForm"
+ #formDir="ngForm"
+ [formGroup]="nfsForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- cluster_id -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="cluster_id">
+ <span class="required"
+ i18n>Cluster</span>
+ <cd-helper>
+ <p i18n>This is the ID of an NFS Service.</p>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="cluster_id"
+ name="cluster_id"
+ id="cluster_id">
+ <option *ngIf="allClusters === null"
+ value=""
+ i18n>Loading...</option>
+ <option *ngIf="allClusters !== null && allClusters.length === 0"
+ value=""
+ i18n>-- No cluster available --</option>
+ <option *ngIf="allClusters !== null && allClusters.length > 0"
+ value=""
+ i18n>-- Select the cluster --</option>
+ <option *ngFor="let cluster of allClusters"
+ [value]="cluster.cluster_id">{{ cluster.cluster_id }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('cluster_id', formDir, 'required') || allClusters?.length === 0"
+ i18n>This field is required.
+ To create a new NFS cluster, <a [routerLink]="['/services', {outlets: {modal: ['create']}}]"
+ class="btn-link">add a new NFS Service</a>.</span>
+ </div>
+ </div>
+
+ <!-- FSAL -->
+ <div formGroupName="fsal">
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="name"
+ i18n>Storage Backend</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="name"
+ name="name"
+ id="name"
+ (change)="fsalChangeHandler()">
+ <option *ngIf="allFsals === null"
+ value=""
+ i18n>Loading...</option>
+ <option *ngIf="allFsals !== null && allFsals.length === 0"
+ value=""
+ i18n>-- No data pools available --</option>
+ <option *ngIf="allFsals !== null && allFsals.length > 0"
+ value=""
+ i18n>-- Select the storage backend --</option>
+ <option *ngFor="let fsal of allFsals"
+ [value]="fsal.value"
+ [disabled]="fsal.disabled">{{ fsal.descr }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('name', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="fsalAvailabilityError"
+ i18n>{{ fsalAvailabilityError }}</span>
+ </div>
+ </div>
+
+ <!-- CephFS Volume -->
+ <div class="form-group row"
+ *ngIf="nfsForm.getValue('name') === 'CEPH'">
+ <label class="cd-col-form-label required"
+ for="fs_name"
+ i18n>Volume</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="fs_name"
+ name="fs_name"
+ id="fs_name"
+ (change)="pathChangeHandler()">
+ <option *ngIf="allFsNames === null"
+ value=""
+ i18n>Loading...</option>
+ <option *ngIf="allFsNames !== null && allFsNames.length === 0"
+ value=""
+ i18n>-- No CephFS filesystem available --</option>
+ <option *ngIf="allFsNames !== null && allFsNames.length > 0"
+ value=""
+ i18n>-- Select the CephFS filesystem --</option>
+ <option *ngFor="let filesystem of allFsNames"
+ [value]="filesystem.name">{{ filesystem.name }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('fs_name', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Security Label -->
+ <div class="form-group row"
+ *ngIf="nfsForm.getValue('name') === 'CEPH'">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': nfsForm.getValue('security_label')}"
+ for="security_label"
+ i18n>Security Label</label>
+
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ formControlName="security_label"
+ name="security_label"
+ id="security_label">
+ <label for="security_label"
+ class="custom-control-label"
+ i18n>Enable security label</label>
+ </div>
+
+ <br>
+
+ <input type="text"
+ *ngIf="nfsForm.getValue('security_label')"
+ class="form-control"
+ name="sec_label_xattr"
+ id="sec_label_xattr"
+ formControlName="sec_label_xattr">
+
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('sec_label_xattr', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Path -->
+ <div class="form-group row"
+ *ngIf="nfsForm.getValue('name') === 'CEPH'">
+ <label class="cd-col-form-label"
+ for="path">
+ <span class="required"
+ i18n>CephFS Path</span>
+ <cd-helper>
+ <p i18n>A path in a CephFS file system.</p>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ class="form-control"
+ name="path"
+ id="path"
+ data-testid="fs_path"
+ formControlName="path"
+ [ngbTypeahead]="pathDataSource"
+ (selectItem)="pathChangeHandler()"
+ (blur)="pathChangeHandler()">
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('path', formDir, 'required')"
+ i18n>This field is required.</span>
+
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('path', formDir, 'pattern')"
+ i18n>Path need to start with a '/' and can be followed by a word</span>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('path', formDir, 'pathNameNotAllowed')"
+ i18n>The path does not exist in the selected volume.</span>
+ </div>
+ </div>
+
+ <!-- Bucket -->
+ <div class="form-group row"
+ *ngIf="nfsForm.getValue('name') === 'RGW'">
+ <label class="cd-col-form-label"
+ for="path">
+ <span class="required"
+ i18n>Bucket</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ class="form-control"
+ name="path"
+ id="path"
+ data-testid="rgw_path"
+ formControlName="path"
+ [ngbTypeahead]="bucketDataSource">
+
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('path', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('path', formDir, 'bucketNameNotAllowed')"
+ i18n>The bucket does not exist or is not in the default realm (if multiple realms are configured).
+ To continue, <a routerLink="/rgw/bucket/create"
+ class="btn-link">create a new bucket</a>.</span>
+ </div>
+ </div>
+
+ <!-- NFS Protocol -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="protocols"
+ i18n>NFS Protocol</label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ formControlName="protocolNfsv4"
+ name="protocolNfsv4"
+ id="protocolNfsv4"
+ disabled>
+ <label i18n
+ class="custom-control-label"
+ for="protocolNfsv4">NFSv4</label>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('protocolNfsv4', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Pseudo -->
+ <div class="form-group row"
+ *ngIf="nfsForm.getValue('protocolNfsv4')">
+ <label class="cd-col-form-label"
+ for="pseudo">
+ <span class="required"
+ i18n>Pseudo</span>
+ <cd-helper>
+ <p i18n>The position that this <strong>NFS v4</strong> export occupies
+ in the <strong>Pseudo FS</strong> (it must be unique).</p>
+ <p i18n>By using different Pseudo options, the same Path may be exported multiple times.</p>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ class="form-control"
+ name="pseudo"
+ id="pseudo"
+ formControlName="pseudo">
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('pseudo', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('pseudo', formDir, 'pseudoAlreadyExists')"
+ i18n>The pseudo is already in use by another export.</span>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('pseudo', formDir, 'pattern')"
+ i18n>Pseudo needs to start with a '/' and can't contain any of the following: &gt;, &lt;, |, &, ( or ).</span>
+ </div>
+ </div>
+
+ <!-- Access Type -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="access_type"
+ i18n>Access Type</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="access_type"
+ name="access_type"
+ id="access_type"
+ (change)="accessTypeChangeHandler()">
+ <option *ngIf="nfsAccessType === null"
+ value=""
+ i18n>Loading...</option>
+ <option *ngIf="nfsAccessType !== null && nfsAccessType.length === 0"
+ value=""
+ i18n>-- No access type available --</option>
+ <option *ngFor="let accessType of nfsAccessType"
+ [value]="accessType.value">{{ accessType.value }}</option>
+ </select>
+ <span class="form-text text-muted"
+ *ngIf="nfsForm.getValue('access_type')">
+ {{ getAccessTypeHelp(nfsForm.getValue('access_type')) }}
+ </span>
+ <span class="form-text text-warning"
+ *ngIf="nfsForm.getValue('access_type') === 'RW' && nfsForm.getValue('name') === 'RGW'"
+ i18n>The Object Gateway NFS backend has a number of
+ limitations which will seriously affect applications writing to
+ the share. Please consult the <cd-doc section="rgw-nfs"></cd-doc>
+ for details before enabling write access.</span>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('access_type', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Squash -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="squash">
+ <span i18n>Squash</span>
+ <ng-container *ngTemplateOutlet="squashHelper"></ng-container>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ name="squash"
+ formControlName="squash"
+ id="squash">
+ <option *ngIf="nfsSquash === null"
+ value=""
+ i18n>Loading...</option>
+ <option *ngIf="nfsSquash !== null && nfsSquash.length === 0"
+ value=""
+ i18n>-- No squash available --</option>
+ <option *ngFor="let squash of nfsSquash"
+ [value]="squash">{{ squash }}</option>
+
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('squash', formDir,'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Transport Protocol -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="transports"
+ i18n>Transport Protocol</label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ formControlName="transportUDP"
+ name="transportUDP"
+ id="transportUDP">
+ <label for="transportUDP"
+ class="custom-control-label"
+ i18n>UDP</label>
+ </div>
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ formControlName="transportTCP"
+ name="transportTCP"
+ id="transportTCP">
+ <label for="transportTCP"
+ class="custom-control-label"
+ i18n>TCP</label>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="nfsForm.showError('transportUDP', formDir, 'required') ||
+ nfsForm.showError('transportTCP', formDir, 'required')"
+ i18n>This field is required.</span>
+ <hr>
+ </div>
+ </div>
+
+ <!-- Clients -->
+ <cd-nfs-form-client [form]="nfsForm"
+ [clients]="clients"
+ #nfsClients>
+ <ng-template #squashHelper>
+ <cd-helper>
+ <ul class="squash-helper">
+ <li>
+ <span class="squash-helper-item-value">no_root_squash: </span>
+ <span i18n>No user id squashing is performed.</span>
+ </li>
+ <li>
+ <span class="squash-helper-item-value">root_id_squash: </span>
+ <span i18n>uid 0 and gid 0 are squashed to the Anonymous_Uid and Anonymous_Gid gid 0 in alt_groups lists is also squashed.</span>
+ </li>
+ <li>
+ <span class="squash-helper-item-value">root_squash: </span>
+ <span i18n>uid 0 and gid of any value are squashed to the Anonymous_Uid and Anonymous_Gid alt_groups lists is discarded.</span>
+ </li>
+ <li>
+ <span class="squash-helper-item-value">all_squash: </span>
+ <span i18n>All users are squashed.</span>
+ </li>
+ </ul>
+ </cd-helper>
+ </ng-template>
+ </cd-nfs-form-client>
+
+ </div>
+
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submitAction()"
+ [form]="nfsForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.scss
new file mode 100644
index 000000000..4d892a120
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.scss
@@ -0,0 +1,11 @@
+.cd-mb {
+ margin-bottom: 10px;
+}
+
+.squash-helper {
+ padding-left: 1rem;
+}
+
+.squash-helper-item-value {
+ font-weight: bold;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts
new file mode 100644
index 000000000..62efec423
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.spec.ts
@@ -0,0 +1,238 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { Observable, of } from 'rxjs';
+
+import { NfsFormClientComponent } from '~/app/ceph/nfs/nfs-form-client/nfs-form-client.component';
+import { NfsFormComponent } from '~/app/ceph/nfs/nfs-form/nfs-form.component';
+import { Directory } from '~/app/shared/api/nfs.service';
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper';
+
+describe('NfsFormComponent', () => {
+ let component: NfsFormComponent;
+ let fixture: ComponentFixture<NfsFormComponent>;
+ let httpTesting: HttpTestingController;
+ let activatedRoute: ActivatedRouteStub;
+
+ configureTestBed(
+ {
+ declarations: [NfsFormComponent, NfsFormClientComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbTypeaheadModule
+ ],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: new ActivatedRouteStub({ cluster_id: 'mynfs', export_id: '1' })
+ }
+ ]
+ },
+ [LoadingPanelComponent]
+ );
+
+ const matchSquash = (backendSquashValue: string, uiSquashValue: string) => {
+ component.ngOnInit();
+ httpTesting.expectOne('ui-api/nfs-ganesha/fsals').flush(['CEPH', 'RGW']);
+ httpTesting.expectOne('ui-api/nfs-ganesha/cephfs/filesystems').flush([{ id: 1, name: 'a' }]);
+ httpTesting.expectOne('api/nfs-ganesha/cluster').flush(['mynfs']);
+ httpTesting.expectOne('api/nfs-ganesha/export/mynfs/1').flush({
+ fsal: {
+ name: 'RGW'
+ },
+ export_id: 1,
+ transports: ['TCP', 'UDP'],
+ protocols: [4],
+ clients: [],
+ squash: backendSquashValue
+ });
+ httpTesting.verify();
+ expect(component.nfsForm.value).toMatchObject({
+ squash: uiSquashValue
+ });
+ };
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NfsFormComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.inject(HttpTestingController);
+ activatedRoute = <ActivatedRouteStub>TestBed.inject(ActivatedRoute);
+ RgwHelper.selectDaemon();
+ fixture.detectChanges();
+
+ httpTesting.expectOne('ui-api/nfs-ganesha/fsals').flush(['CEPH', 'RGW']);
+ httpTesting.expectOne('ui-api/nfs-ganesha/cephfs/filesystems').flush([{ id: 1, name: 'a' }]);
+ httpTesting.expectOne('api/nfs-ganesha/cluster').flush(['mynfs']);
+ httpTesting.verify();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should process all data', () => {
+ expect(component.allFsals).toEqual([
+ { descr: 'CephFS', value: 'CEPH', disabled: false },
+ { descr: 'Object Gateway', value: 'RGW', disabled: false }
+ ]);
+ expect(component.allFsNames).toEqual([{ id: 1, name: 'a' }]);
+ expect(component.allClusters).toEqual([{ cluster_id: 'mynfs' }]);
+ });
+
+ it('should create the form', () => {
+ expect(component.nfsForm.value).toEqual({
+ access_type: 'RW',
+ clients: [],
+ cluster_id: 'mynfs',
+ fsal: { fs_name: 'a', name: 'CEPH' },
+ path: '/',
+ protocolNfsv4: true,
+ pseudo: '',
+ sec_label_xattr: 'security.selinux',
+ security_label: false,
+ squash: 'no_root_squash',
+ transportTCP: true,
+ transportUDP: true
+ });
+ expect(component.nfsForm.get('cluster_id').disabled).toBeFalsy();
+ });
+
+ it('should prepare data when selecting an cluster', () => {
+ component.nfsForm.patchValue({ cluster_id: 'cluster1' });
+
+ component.nfsForm.patchValue({ cluster_id: 'cluster2' });
+ });
+
+ it('should not allow changing cluster in edit mode', () => {
+ component.isEdit = true;
+ component.ngOnInit();
+ expect(component.nfsForm.get('cluster_id').disabled).toBeTruthy();
+ });
+
+ it('should mark NFSv4 protocol as enabled always', () => {
+ expect(component.nfsForm.get('protocolNfsv4')).toBeTruthy();
+ });
+
+ it('should match backend squash values with ui values', () => {
+ component.isEdit = true;
+ matchSquash('none', 'no_root_squash');
+ matchSquash('all', 'all_squash');
+ matchSquash('rootid', 'root_id_squash');
+ matchSquash('root', 'root_squash');
+ });
+
+ describe('should submit request', () => {
+ beforeEach(() => {
+ component.nfsForm.patchValue({
+ access_type: 'RW',
+ clients: [],
+ cluster_id: 'cluster1',
+ fsal: { name: 'CEPH', fs_name: 1 },
+ path: '/foo',
+ protocolNfsv4: true,
+ pseudo: '/baz',
+ squash: 'no_root_squash',
+ transportTCP: true,
+ transportUDP: true
+ });
+ });
+
+ it('should call update', () => {
+ activatedRoute.setParams({ cluster_id: 'cluster1', export_id: '1' });
+ component.isEdit = true;
+ component.cluster_id = 'cluster1';
+ component.export_id = '1';
+ component.nfsForm.patchValue({ export_id: 1 });
+ component.submitAction();
+
+ const req = httpTesting.expectOne('api/nfs-ganesha/export/cluster1/1');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({
+ access_type: 'RW',
+ clients: [],
+ cluster_id: 'cluster1',
+ export_id: 1,
+ fsal: { fs_name: 1, name: 'CEPH', sec_label_xattr: null },
+ path: '/foo',
+ protocols: [4],
+ pseudo: '/baz',
+ security_label: false,
+ squash: 'no_root_squash',
+ transports: ['TCP', 'UDP']
+ });
+ });
+
+ it('should call create', () => {
+ activatedRoute.setParams({ cluster_id: undefined, export_id: undefined });
+ component.submitAction();
+
+ const req = httpTesting.expectOne('api/nfs-ganesha/export');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({
+ access_type: 'RW',
+ clients: [],
+ cluster_id: 'cluster1',
+ fsal: {
+ fs_name: 1,
+ name: 'CEPH',
+ sec_label_xattr: null
+ },
+ path: '/foo',
+ protocols: [4],
+ pseudo: '/baz',
+ security_label: false,
+ squash: 'no_root_squash',
+ transports: ['TCP', 'UDP']
+ });
+ });
+ });
+
+ describe('pathExistence', () => {
+ beforeEach(() => {
+ component['nfsService']['lsDir'] = jest.fn(
+ (): Observable<Directory> => of({ paths: ['/path1'] })
+ );
+ component.nfsForm.get('name').setValue('CEPH');
+ component.setPathValidation();
+ });
+
+ const testValidator = (pathName: string, valid: boolean, expectedError?: string) => {
+ const path = component.nfsForm.get('path');
+ path.setValue(pathName);
+ path.markAsDirty();
+ path.updateValueAndValidity();
+
+ if (valid) {
+ expect(path.errors).toBe(null);
+ } else {
+ expect(path.hasError(expectedError)).toBeTruthy();
+ }
+ };
+
+ it('path cannot be empty', () => {
+ testValidator('', false, 'required');
+ });
+
+ it('path that does not exist should be invalid', () => {
+ testValidator('/path2', false, 'pathNameNotAllowed');
+ expect(component['nfsService']['lsDir']).toHaveBeenCalledTimes(1);
+ });
+
+ it('path that exists should be valid', () => {
+ testValidator('/path1', true);
+ expect(component['nfsService']['lsDir']).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts
new file mode 100644
index 000000000..595b3b7fe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-form/nfs-form.component.ts
@@ -0,0 +1,535 @@
+import { ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core';
+import {
+ AbstractControl,
+ AsyncValidatorFn,
+ FormControl,
+ ValidationErrors,
+ Validators
+} from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import { forkJoin, Observable, of } from 'rxjs';
+import { catchError, debounceTime, distinctUntilChanged, map, mergeMap } from 'rxjs/operators';
+
+import { NfsFSAbstractionLayer } from '~/app/ceph/nfs/models/nfs.fsal';
+import { Directory, NfsService } from '~/app/shared/api/nfs.service';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { RgwSiteService } from '~/app/shared/api/rgw-site.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+import { CdHttpErrorResponse } from '~/app/shared/services/api-interceptor.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NfsFormClientComponent } from '../nfs-form-client/nfs-form-client.component';
+
+@Component({
+ selector: 'cd-nfs-form',
+ templateUrl: './nfs-form.component.html',
+ styleUrls: ['./nfs-form.component.scss']
+})
+export class NfsFormComponent extends CdForm implements OnInit {
+ @ViewChild('nfsClients', { static: true })
+ nfsClients: NfsFormClientComponent;
+
+ clients: any[] = [];
+
+ permission: Permission;
+ nfsForm: CdFormGroup;
+ isEdit = false;
+
+ cluster_id: string = null;
+ export_id: string = null;
+
+ allClusters: { cluster_id: string }[] = null;
+ icons = Icons;
+
+ allFsals: any[] = [];
+ allFsNames: any[] = null;
+ fsalAvailabilityError: string = null;
+
+ defaultAccessType = { RGW: 'RO' };
+ nfsAccessType: any[] = this.nfsService.nfsAccessType;
+ nfsSquash: any[] = Object.keys(this.nfsService.nfsSquash);
+
+ action: string;
+ resource: string;
+
+ pathDataSource = (text$: Observable<string>) => {
+ return text$.pipe(
+ debounceTime(200),
+ distinctUntilChanged(),
+ mergeMap((token: string) => this.getPathTypeahead(token)),
+ map((val: string[]) => val)
+ );
+ };
+
+ bucketDataSource = (text$: Observable<string>) => {
+ return text$.pipe(
+ debounceTime(200),
+ distinctUntilChanged(),
+ mergeMap((token: string) => this.getBucketTypeahead(token))
+ );
+ };
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private nfsService: NfsService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private rgwBucketService: RgwBucketService,
+ private rgwSiteService: RgwSiteService,
+ private formBuilder: CdFormBuilder,
+ private taskWrapper: TaskWrapperService,
+ private cdRef: ChangeDetectorRef,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().pool;
+ this.resource = $localize`NFS export`;
+ this.createForm();
+ }
+
+ ngOnInit() {
+ const promises: Observable<any>[] = [
+ this.nfsService.listClusters(),
+ this.nfsService.fsals(),
+ this.nfsService.filesystems()
+ ];
+
+ if (this.router.url.startsWith('/nfs/edit')) {
+ this.isEdit = true;
+ }
+
+ if (this.isEdit) {
+ this.action = this.actionLabels.EDIT;
+ this.route.params.subscribe((params: { cluster_id: string; export_id: string }) => {
+ this.cluster_id = decodeURIComponent(params.cluster_id);
+ this.export_id = decodeURIComponent(params.export_id);
+ promises.push(this.nfsService.get(this.cluster_id, this.export_id));
+
+ this.getData(promises);
+ });
+ this.nfsForm.get('cluster_id').disable();
+ } else {
+ this.action = this.actionLabels.CREATE;
+ this.getData(promises);
+ }
+ }
+
+ getData(promises: Observable<any>[]) {
+ forkJoin(promises).subscribe((data: any[]) => {
+ this.resolveClusters(data[0]);
+ this.resolveFsals(data[1]);
+ this.resolveFilesystems(data[2]);
+ if (data[3]) {
+ this.resolveModel(data[3]);
+ }
+
+ this.loadingReady();
+ });
+ }
+
+ createForm() {
+ this.nfsForm = new CdFormGroup({
+ cluster_id: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ fsal: new CdFormGroup({
+ name: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ fs_name: new FormControl('', {
+ validators: [
+ CdValidators.requiredIf({
+ name: 'CEPH'
+ })
+ ]
+ })
+ }),
+ path: new FormControl('/'),
+ protocolNfsv4: new FormControl(true),
+ pseudo: new FormControl('', {
+ validators: [
+ CdValidators.requiredIf({ protocolNfsv4: true }),
+ Validators.pattern('^/[^><|&()]*$')
+ ]
+ }),
+ access_type: new FormControl('RW'),
+ squash: new FormControl(this.nfsSquash[0]),
+ transportUDP: new FormControl(true, {
+ validators: [
+ CdValidators.requiredIf({ transportTCP: false }, (value: boolean) => {
+ return !value;
+ })
+ ]
+ }),
+ transportTCP: new FormControl(true, {
+ validators: [
+ CdValidators.requiredIf({ transportUDP: false }, (value: boolean) => {
+ return !value;
+ })
+ ]
+ }),
+ clients: this.formBuilder.array([]),
+ security_label: new FormControl(false),
+ sec_label_xattr: new FormControl(
+ 'security.selinux',
+ CdValidators.requiredIf({ security_label: true, 'fsal.name': 'CEPH' })
+ )
+ });
+ }
+
+ resolveModel(res: any) {
+ if (res.fsal.name === 'CEPH') {
+ res.sec_label_xattr = res.fsal.sec_label_xattr;
+ }
+
+ res.protocolNfsv4 = res.protocols.indexOf(4) !== -1;
+ delete res.protocols;
+
+ res.transportTCP = res.transports.indexOf('TCP') !== -1;
+ res.transportUDP = res.transports.indexOf('UDP') !== -1;
+ delete res.transports;
+
+ Object.entries(this.nfsService.nfsSquash).forEach(([key, value]) => {
+ if (value.includes(res.squash)) {
+ res.squash = key;
+ }
+ });
+
+ res.clients.forEach((client: any) => {
+ let addressStr = '';
+ client.addresses.forEach((address: string) => {
+ addressStr += address + ', ';
+ });
+ if (addressStr.length >= 2) {
+ addressStr = addressStr.substring(0, addressStr.length - 2);
+ }
+ client.addresses = addressStr;
+ });
+
+ this.nfsForm.patchValue(res);
+ this.setPathValidation();
+ this.clients = res.clients;
+ }
+
+ resolveClusters(clusters: string[]) {
+ this.allClusters = [];
+ for (const cluster of clusters) {
+ this.allClusters.push({ cluster_id: cluster });
+ }
+ if (!this.isEdit && this.allClusters.length > 0) {
+ this.nfsForm.get('cluster_id').setValue(this.allClusters[0].cluster_id);
+ }
+ }
+
+ resolveFsals(res: string[]) {
+ res.forEach((fsal) => {
+ const fsalItem = this.nfsService.nfsFsal.find((currentFsalItem) => {
+ return fsal === currentFsalItem.value;
+ });
+
+ if (_.isObjectLike(fsalItem)) {
+ this.allFsals.push(fsalItem);
+ }
+ });
+ if (!this.isEdit && this.allFsals.length > 0) {
+ this.nfsForm.patchValue({
+ fsal: {
+ name: this.allFsals[0].value
+ }
+ });
+ }
+ }
+
+ resolveFilesystems(filesystems: any[]) {
+ this.allFsNames = filesystems;
+ if (!this.isEdit && filesystems.length > 0) {
+ this.nfsForm.patchValue({
+ fsal: {
+ fs_name: filesystems[0].name
+ }
+ });
+ }
+ }
+
+ fsalChangeHandler() {
+ this.setPathValidation();
+ const fsalValue = this.nfsForm.getValue('name');
+ const checkAvailability =
+ fsalValue === 'RGW'
+ ? this.rgwSiteService.get('realms').pipe(
+ mergeMap((realms: string[]) =>
+ realms.length === 0
+ ? of(true)
+ : this.rgwSiteService.isDefaultRealm().pipe(
+ mergeMap((isDefaultRealm) => {
+ if (!isDefaultRealm) {
+ throw new Error('Selected realm is not the default.');
+ }
+ return of(true);
+ })
+ )
+ )
+ )
+ : this.nfsService.filesystems();
+
+ checkAvailability.subscribe({
+ next: () => {
+ this.setFsalAvailability(fsalValue, true);
+ if (!this.isEdit) {
+ this.nfsForm.patchValue({
+ path: fsalValue === 'RGW' ? '' : '/',
+ pseudo: this.generatePseudo(),
+ access_type: this.updateAccessType()
+ });
+ }
+
+ this.cdRef.detectChanges();
+ },
+ error: (error) => {
+ this.setFsalAvailability(fsalValue, false, error);
+ this.nfsForm.get('name').setValue('');
+ }
+ });
+ }
+
+ private setFsalAvailability(fsalValue: string, available: boolean, errorMessage: string = '') {
+ this.allFsals = this.allFsals.map((fsalItem: NfsFSAbstractionLayer) => {
+ if (fsalItem.value === fsalValue) {
+ fsalItem.disabled = !available;
+
+ this.fsalAvailabilityError = fsalItem.disabled
+ ? $localize`${fsalItem.descr} backend is not available. ${errorMessage}`
+ : null;
+ }
+ return fsalItem;
+ });
+ }
+
+ accessTypeChangeHandler() {
+ const name = this.nfsForm.getValue('name');
+ const accessType = this.nfsForm.getValue('access_type');
+ this.defaultAccessType[name] = accessType;
+ }
+
+ setPathValidation() {
+ const path = this.nfsForm.get('path');
+ path.setValidators([Validators.required]);
+ if (this.nfsForm.getValue('name') === 'RGW') {
+ path.setAsyncValidators([CdValidators.bucketExistence(true, this.rgwBucketService)]);
+ } else {
+ path.setAsyncValidators([this.pathExistence(true)]);
+ }
+
+ if (this.isEdit) {
+ path.markAsDirty();
+ }
+ }
+
+ getAccessTypeHelp(accessType: string) {
+ const accessTypeItem = this.nfsAccessType.find((currentAccessTypeItem) => {
+ if (accessType === currentAccessTypeItem.value) {
+ return currentAccessTypeItem;
+ }
+ });
+ return _.isObjectLike(accessTypeItem) ? accessTypeItem.help : '';
+ }
+
+ getId() {
+ if (
+ _.isString(this.nfsForm.getValue('cluster_id')) &&
+ _.isString(this.nfsForm.getValue('path'))
+ ) {
+ return this.nfsForm.getValue('cluster_id') + ':' + this.nfsForm.getValue('path');
+ }
+ return '';
+ }
+
+ private getPathTypeahead(path: any) {
+ if (!_.isString(path) || path === '/') {
+ return of([]);
+ }
+
+ const fsName = this.nfsForm.getValue('fsal').fs_name;
+ return this.nfsService.lsDir(fsName, path).pipe(
+ map((result: Directory) =>
+ result.paths.filter((dirName: string) => dirName.toLowerCase().includes(path)).slice(0, 15)
+ ),
+ catchError(() => of([$localize`Error while retrieving paths.`]))
+ );
+ }
+
+ pathChangeHandler() {
+ if (!this.isEdit) {
+ this.nfsForm.patchValue({
+ pseudo: this.generatePseudo()
+ });
+ }
+ }
+
+ private getBucketTypeahead(path: string): Observable<any> {
+ if (_.isString(path) && path !== '/' && path !== '') {
+ return this.rgwBucketService.list().pipe(
+ map((bucketList) =>
+ bucketList
+ .filter((bucketName: string) => bucketName.toLowerCase().includes(path))
+ .slice(0, 15)
+ ),
+ catchError(() => of([$localize`Error while retrieving bucket names.`]))
+ );
+ } else {
+ return of([]);
+ }
+ }
+
+ private generatePseudo() {
+ let newPseudo = this.nfsForm.getValue('pseudo');
+ if (this.nfsForm.get('pseudo') && !this.nfsForm.get('pseudo').dirty) {
+ newPseudo = undefined;
+ if (this.nfsForm.getValue('fsal') === 'CEPH') {
+ newPseudo = '/cephfs';
+ if (_.isString(this.nfsForm.getValue('path'))) {
+ newPseudo += this.nfsForm.getValue('path');
+ }
+ }
+ }
+ return newPseudo;
+ }
+
+ private updateAccessType() {
+ const name = this.nfsForm.getValue('name');
+ let accessType = this.defaultAccessType[name];
+
+ if (!accessType) {
+ accessType = 'RW';
+ }
+
+ return accessType;
+ }
+
+ submitAction() {
+ let action: Observable<any>;
+ const requestModel = this.buildRequest();
+
+ if (this.isEdit) {
+ action = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('nfs/edit', {
+ cluster_id: this.cluster_id,
+ export_id: _.parseInt(this.export_id)
+ }),
+ call: this.nfsService.update(this.cluster_id, _.parseInt(this.export_id), requestModel)
+ });
+ } else {
+ // Create
+ action = this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('nfs/create', {
+ path: requestModel.path,
+ fsal: requestModel.fsal,
+ cluster_id: requestModel.cluster_id
+ }),
+ call: this.nfsService.create(requestModel)
+ });
+ }
+
+ action.subscribe({
+ error: (errorResponse: CdHttpErrorResponse) => this.setFormErrors(errorResponse),
+ complete: () => this.router.navigate(['/nfs'])
+ });
+ }
+
+ private setFormErrors(errorResponse: CdHttpErrorResponse) {
+ if (
+ errorResponse.error.detail &&
+ errorResponse.error.detail
+ .toString()
+ .includes(`Pseudo ${this.nfsForm.getValue('pseudo')} is already in use`)
+ ) {
+ this.nfsForm.get('pseudo').setErrors({ pseudoAlreadyExists: true });
+ }
+ this.nfsForm.setErrors({ cdSubmitButton: true });
+ }
+
+ private buildRequest() {
+ const requestModel: any = _.cloneDeep(this.nfsForm.value);
+
+ if (this.isEdit) {
+ requestModel.export_id = _.parseInt(this.export_id);
+ }
+
+ if (requestModel.fsal.name === 'RGW') {
+ delete requestModel.fsal.fs_name;
+ }
+
+ requestModel.protocols = [];
+ if (requestModel.protocolNfsv4) {
+ requestModel.protocols.push(4);
+ } else {
+ requestModel.pseudo = null;
+ }
+ delete requestModel.protocolNfsv4;
+
+ requestModel.transports = [];
+ if (requestModel.transportTCP) {
+ requestModel.transports.push('TCP');
+ }
+ delete requestModel.transportTCP;
+ if (requestModel.transportUDP) {
+ requestModel.transports.push('UDP');
+ }
+ delete requestModel.transportUDP;
+
+ requestModel.clients.forEach((client: any) => {
+ if (_.isString(client.addresses)) {
+ client.addresses = _(client.addresses)
+ .split(/[ ,]+/)
+ .uniq()
+ .filter((address) => address !== '')
+ .value();
+ } else {
+ client.addresses = [];
+ }
+
+ if (!client.squash) {
+ client.squash = requestModel.squash;
+ }
+
+ if (!client.access_type) {
+ client.access_type = requestModel.access_type;
+ }
+ });
+
+ if (requestModel.security_label === false || requestModel.fsal.name === 'RGW') {
+ requestModel.fsal.sec_label_xattr = null;
+ } else {
+ requestModel.fsal.sec_label_xattr = requestModel.sec_label_xattr;
+ }
+ delete requestModel.sec_label_xattr;
+
+ return requestModel;
+ }
+
+ private pathExistence(requiredExistenceResult: boolean): AsyncValidatorFn {
+ return (control: AbstractControl): Observable<ValidationErrors | null> => {
+ if (control.pristine || !control.value) {
+ return of({ required: true });
+ }
+ const fsName = this.nfsForm.getValue('fsal').fs_name;
+ return this.nfsService.lsDir(fsName, control.value).pipe(
+ map((directory: Directory) =>
+ directory.paths.includes(control.value) === requiredExistenceResult
+ ? null
+ : { pathNameNotAllowed: true }
+ ),
+ catchError(() => of({ pathNameNotAllowed: true }))
+ );
+ };
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html
new file mode 100644
index 000000000..79304265e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html
@@ -0,0 +1,30 @@
+<cd-table #table
+ [data]="exports"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="id"
+ forceIdentifier="true"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions class="btn-group"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+
+ <cd-nfs-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-nfs-details>
+</cd-table>
+
+<ng-template #nfsFsal
+ let-value="value">
+ <ng-container *ngIf="value.name==='CEPH'"
+ i18n>CephFS</ng-container>
+ <ng-container *ngIf="value.name==='RGW'"
+ i18n>Object Gateway</ng-container>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts
new file mode 100644
index 000000000..5e43cdd65
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts
@@ -0,0 +1,195 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { NfsService } from '~/app/shared/api/nfs.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { Summary } from '~/app/shared/models/summary.model';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, expectItemTasks, PermissionHelper } from '~/testing/unit-test-helper';
+import { NfsDetailsComponent } from '../nfs-details/nfs-details.component';
+import { NfsListComponent } from './nfs-list.component';
+
+describe('NfsListComponent', () => {
+ let component: NfsListComponent;
+ let fixture: ComponentFixture<NfsListComponent>;
+ let summaryService: SummaryService;
+ let nfsService: NfsService;
+ let httpTesting: HttpTestingController;
+
+ const refresh = (data: Summary) => {
+ summaryService['summaryDataSource'].next(data);
+ };
+
+ configureTestBed({
+ declarations: [NfsListComponent, NfsDetailsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ NgbNavModule,
+ ToastrModule.forRoot()
+ ],
+ providers: [TaskListService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NfsListComponent);
+ component = fixture.componentInstance;
+ summaryService = TestBed.inject(SummaryService);
+ nfsService = TestBed.inject(NfsService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('after ngOnInit', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ spyOn(nfsService, 'list').and.callThrough();
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should load exports on init', () => {
+ refresh(new Summary());
+ httpTesting.expectOne('api/nfs-ganesha/export');
+ expect(nfsService.list).toHaveBeenCalled();
+ });
+
+ it('should not load images on init because no data', () => {
+ refresh(undefined);
+ expect(nfsService.list).not.toHaveBeenCalled();
+ });
+
+ it('should call error function on init when summary service fails', () => {
+ spyOn(component.table, 'reset');
+ summaryService['summaryDataSource'].error(undefined);
+ expect(component.table.reset).toHaveBeenCalled();
+ });
+ });
+
+ describe('handling of executing tasks', () => {
+ let exports: any[];
+
+ const addExport = (export_id: string) => {
+ const model = {
+ export_id: export_id,
+ path: 'path_' + export_id,
+ fsal: 'fsal_' + export_id,
+ cluster_id: 'cluster_' + export_id
+ };
+ exports.push(model);
+ };
+
+ const addTask = (name: string, export_id: string) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ switch (task.name) {
+ case 'nfs/create':
+ task.metadata = {
+ path: 'path_' + export_id,
+ fsal: 'fsal_' + export_id,
+ cluster_id: 'cluster_' + export_id
+ };
+ break;
+ default:
+ task.metadata = {
+ cluster_id: 'cluster_' + export_id,
+ export_id: export_id
+ };
+ break;
+ }
+ summaryService.addRunningTask(task);
+ };
+
+ beforeEach(() => {
+ exports = [];
+ addExport('a');
+ addExport('b');
+ addExport('c');
+ component.exports = exports;
+ refresh(new Summary());
+ spyOn(nfsService, 'list').and.callFake(() => of(exports));
+ fixture.detectChanges();
+ });
+
+ it('should gets all exports without tasks', () => {
+ expect(component.exports.length).toBe(3);
+ expect(component.exports.every((expo) => !expo.cdExecuting)).toBeTruthy();
+ });
+
+ it('should add a new export from a task', fakeAsync(() => {
+ addTask('nfs/create', 'd');
+ tick();
+ expect(component.exports.length).toBe(4);
+ expectItemTasks(component.exports[0], undefined);
+ expectItemTasks(component.exports[1], undefined);
+ expectItemTasks(component.exports[2], undefined);
+ expectItemTasks(component.exports[3], 'Creating');
+ }));
+
+ it('should show when an existing export is being modified', () => {
+ addTask('nfs/edit', 'a');
+ addTask('nfs/delete', 'b');
+ expect(component.exports.length).toBe(3);
+ expectItemTasks(component.exports[0], 'Updating');
+ expectItemTasks(component.exports[1], 'Deleting');
+ });
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Edit', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Delete', single: 'Delete', no: 'Create' }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Delete'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts
new file mode 100644
index 000000000..d5d0c2639
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts
@@ -0,0 +1,199 @@
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Subscription } from 'rxjs';
+
+import { NfsService } from '~/app/shared/api/nfs.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } 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';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+import { Task } from '~/app/shared/models/task';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-nfs-list',
+ templateUrl: './nfs-list.component.html',
+ styleUrls: ['./nfs-list.component.scss'],
+ providers: [TaskListService]
+})
+export class NfsListComponent extends ListWithDetails implements OnInit, OnDestroy {
+ @ViewChild('nfsState')
+ nfsState: TemplateRef<any>;
+ @ViewChild('nfsFsal', { static: true })
+ nfsFsal: TemplateRef<any>;
+
+ @ViewChild('table', { static: true })
+ table: TableComponent;
+
+ columns: CdTableColumn[];
+ permission: Permission;
+ selection = new CdTableSelection();
+ summaryDataSubscription: Subscription;
+ viewCacheStatus: any;
+ exports: any[];
+ tableActions: CdTableAction[];
+ isDefaultCluster = false;
+
+ modalRef: NgbModalRef;
+
+ builders = {
+ 'nfs/create': (metadata: any) => {
+ return {
+ path: metadata['path'],
+ cluster_id: metadata['cluster_id'],
+ fsal: metadata['fsal']
+ };
+ }
+ };
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private modalService: ModalService,
+ private nfsService: NfsService,
+ private taskListService: TaskListService,
+ private taskWrapper: TaskWrapperService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().nfs;
+ const getNfsUri = () =>
+ this.selection.first() &&
+ `${encodeURI(this.selection.first().cluster_id)}/${encodeURI(
+ this.selection.first().export_id
+ )}`;
+
+ const createAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => '/nfs/create',
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection,
+ name: this.actionLabels.CREATE
+ };
+
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () => `/nfs/edit/${getNfsUri()}`,
+ name: this.actionLabels.EDIT
+ };
+
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteNfsModal(),
+ name: this.actionLabels.DELETE
+ };
+
+ this.tableActions = [createAction, editAction, deleteAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Path`,
+ prop: 'path',
+ flexGrow: 2,
+ cellTransformation: CellTemplate.executing
+ },
+ {
+ name: $localize`Pseudo`,
+ prop: 'pseudo',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Cluster`,
+ prop: 'cluster_id',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Storage Backend`,
+ prop: 'fsal',
+ flexGrow: 2,
+ cellTemplate: this.nfsFsal
+ },
+ {
+ name: $localize`Access Type`,
+ prop: 'access_type',
+ flexGrow: 2
+ }
+ ];
+
+ this.taskListService.init(
+ () => this.nfsService.list(),
+ (resp) => this.prepareResponse(resp),
+ (exports) => (this.exports = exports),
+ () => this.onFetchError(),
+ this.taskFilter,
+ this.itemFilter,
+ this.builders
+ );
+ }
+
+ ngOnDestroy() {
+ if (this.summaryDataSubscription) {
+ this.summaryDataSubscription.unsubscribe();
+ }
+ }
+
+ prepareResponse(resp: any): any[] {
+ let result: any[] = [];
+ resp.forEach((nfs: any) => {
+ nfs.id = `${nfs.cluster_id}:${nfs.export_id}`;
+ nfs.state = 'LOADING';
+ result = result.concat(nfs);
+ });
+
+ return result;
+ }
+
+ onFetchError() {
+ this.table.reset(); // Disable loading indicator.
+ this.viewCacheStatus = { status: ViewCacheStatus.ValueException };
+ }
+
+ itemFilter(entry: any, task: Task) {
+ return (
+ entry.cluster_id === task.metadata['cluster_id'] &&
+ entry.export_id === task.metadata['export_id']
+ );
+ }
+
+ taskFilter(task: Task) {
+ return ['nfs/create', 'nfs/delete', 'nfs/edit'].includes(task.name);
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteNfsModal() {
+ const cluster_id = this.selection.first().cluster_id;
+ const export_id = this.selection.first().export_id;
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: $localize`NFS export`,
+ itemNames: [`${cluster_id}:${export_id}`],
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('nfs/delete', {
+ cluster_id: cluster_id,
+ export_id: export_id
+ }),
+ call: this.nfsService.delete(cluster_id, export_id)
+ })
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts
new file mode 100644
index 000000000..4205eb63b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts
@@ -0,0 +1,26 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { NfsDetailsComponent } from './nfs-details/nfs-details.component';
+import { NfsFormClientComponent } from './nfs-form-client/nfs-form-client.component';
+import { NfsFormComponent } from './nfs-form/nfs-form.component';
+import { NfsListComponent } from './nfs-list/nfs-list.component';
+
+@NgModule({
+ imports: [
+ ReactiveFormsModule,
+ RouterModule,
+ SharedModule,
+ NgbNavModule,
+ CommonModule,
+ NgbTypeaheadModule,
+ NgbTooltipModule
+ ],
+ declarations: [NfsListComponent, NfsDetailsComponent, NfsFormComponent, NfsFormClientComponent]
+})
+export class NfsModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter.module.ts
new file mode 100644
index 000000000..9beb53011
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter.module.ts
@@ -0,0 +1,14 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { PerformanceCounterComponent } from './performance-counter/performance-counter.component';
+import { TablePerformanceCounterComponent } from './table-performance-counter/table-performance-counter.component';
+
+@NgModule({
+ imports: [CommonModule, SharedModule, RouterModule],
+ declarations: [TablePerformanceCounterComponent, PerformanceCounterComponent],
+ exports: [TablePerformanceCounterComponent]
+})
+export class PerformanceCounterModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.html
new file mode 100644
index 000000000..988a8a252
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.html
@@ -0,0 +1,4 @@
+<legend>{{ serviceType }}.{{ serviceId }}</legend>
+<cd-table-performance-counter [serviceType]="serviceType"
+ [serviceId]="serviceId">
+</cd-table-performance-counter>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.spec.ts
new file mode 100644
index 000000000..5d2da8164
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.spec.ts
@@ -0,0 +1,29 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TablePerformanceCounterComponent } from '../table-performance-counter/table-performance-counter.component';
+import { PerformanceCounterComponent } from './performance-counter.component';
+
+describe('PerformanceCounterComponent', () => {
+ let component: PerformanceCounterComponent;
+ let fixture: ComponentFixture<PerformanceCounterComponent>;
+
+ configureTestBed({
+ declarations: [PerformanceCounterComponent, TablePerformanceCounterComponent],
+ imports: [RouterTestingModule, SharedModule, HttpClientTestingModule, BrowserAnimationsModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PerformanceCounterComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.ts
new file mode 100644
index 000000000..9321e0e9a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/performance-counter/performance-counter.component.ts
@@ -0,0 +1,25 @@
+import { Component } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+@Component({
+ selector: 'cd-performance-counter',
+ templateUrl: './performance-counter.component.html',
+ styleUrls: ['./performance-counter.component.scss']
+})
+export class PerformanceCounterComponent {
+ static defaultFromLink = '/hosts';
+
+ serviceId: string;
+ serviceType: string;
+ fromLink: string;
+
+ constructor(private route: ActivatedRoute) {
+ this.route.queryParams.subscribe((params: { fromLink: string }) => {
+ this.fromLink = params.fromLink || PerformanceCounterComponent.defaultFromLink;
+ });
+ this.route.params.subscribe((params: { type: string; id: string }) => {
+ this.serviceId = decodeURIComponent(params.id);
+ this.serviceType = params.type;
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html
new file mode 100644
index 000000000..17c757356
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html
@@ -0,0 +1,15 @@
+<cd-table *ngIf="counters; else warning"
+ [data]="counters"
+ [columns]="columns"
+ columnMode="flex"
+ [autoSave]="false"
+ (fetchData)="getCounters($event)">
+ <ng-template #valueTpl
+ let-row="row">
+ {{ row.value | dimless }} {{ row.unit }}
+ </ng-template>
+</cd-table>
+<ng-template #warning>
+ <cd-alert-panel type="warning"
+ i18n>Performance counters not available</cd-alert-panel>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts
new file mode 100644
index 000000000..fd8264405
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts
@@ -0,0 +1,62 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AppModule } from '~/app/app.module';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TablePerformanceCounterComponent } from './table-performance-counter.component';
+
+describe('TablePerformanceCounterComponent', () => {
+ let component: TablePerformanceCounterComponent;
+ let fixture: ComponentFixture<TablePerformanceCounterComponent>;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [AppModule, HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TablePerformanceCounterComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.inject(HttpTestingController);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.counters).toEqual([]);
+ });
+
+ it('should have columns that are sortable', () => {
+ expect(component.columns.every((column) => Boolean(column.prop))).toBeTruthy();
+ });
+
+ describe('Error handling', () => {
+ const context = new CdTableFetchDataContext(() => undefined);
+
+ beforeEach(() => {
+ spyOn(context, 'error');
+ component.serviceType = 'osd';
+ component.serviceId = '3';
+ component.getCounters(context);
+ });
+
+ it('should display 404 warning', () => {
+ httpTesting
+ .expectOne('api/perf_counters/osd/3')
+ .error(new ErrorEvent('osd.3 not found'), { status: 404 });
+ httpTesting.verify();
+ expect(component.counters).toBeNull();
+ expect(context.error).not.toHaveBeenCalled();
+ });
+
+ it('should call error function of context', () => {
+ httpTesting
+ .expectOne('api/perf_counters/osd/3')
+ .error(new ErrorEvent('Unknown error'), { status: 500 });
+ httpTesting.verify();
+ expect(component.counters).toEqual([]);
+ expect(context.error).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts
new file mode 100644
index 000000000..e2e0194de
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts
@@ -0,0 +1,72 @@
+import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { PerformanceCounterService } from '~/app/shared/api/performance-counter.service';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+
+/**
+ * Display the specified performance counters in a datatable.
+ */
+@Component({
+ selector: 'cd-table-performance-counter',
+ templateUrl: './table-performance-counter.component.html',
+ styleUrls: ['./table-performance-counter.component.scss']
+})
+export class TablePerformanceCounterComponent implements OnInit {
+ columns: Array<CdTableColumn> = [];
+ counters: Array<object> = [];
+
+ @ViewChild('valueTpl')
+ public valueTpl: TemplateRef<any>;
+
+ /**
+ * The service type, e.g. 'rgw', 'mds', 'mon', 'osd', ...
+ */
+ @Input()
+ serviceType: string;
+
+ /**
+ * The service identifier.
+ */
+ @Input()
+ serviceId: string;
+
+ constructor(private performanceCounterService: PerformanceCounterService) {}
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Description`,
+ prop: 'description',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Value`,
+ prop: 'value',
+ cellTemplate: this.valueTpl,
+ flexGrow: 1
+ }
+ ];
+ }
+
+ getCounters(context: CdTableFetchDataContext) {
+ this.performanceCounterService.get(this.serviceType, this.serviceId).subscribe(
+ (resp: object[]) => {
+ this.counters = resp;
+ },
+ (error) => {
+ if (error.status === 404) {
+ error.preventDefault();
+ this.counters = null;
+ } else {
+ context.error();
+ }
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html
new file mode 100644
index 000000000..e5906dc4b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html
@@ -0,0 +1,123 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label for="name"
+ class="cd-col-form-label">
+ <ng-container i18n>Name</ng-container>
+ <span class="required"></span>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ id="name"
+ name="name"
+ class="form-control"
+ placeholder="Name..."
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'pattern')"
+ i18n>The name can only consist of alphanumeric characters, dashes and underscores.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'uniqueName')"
+ i18n>The chosen erasure code profile name is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Root -->
+ <div class="form-group row">
+ <label for="root"
+ class="cd-col-form-label">
+ <ng-container i18n>Root</ng-container>
+ <cd-helper [html]="tooltips.root">
+ </cd-helper>
+ <span class="required"></span>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="root"
+ name="root"
+ formControlName="root">
+ <option *ngIf="!buckets"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let bucket of buckets"
+ [ngValue]="bucket">
+ {{ bucket.name }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('root', frm, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Failure Domain Type -->
+ <div class="form-group row">
+ <label for="failure_domain"
+ class="cd-col-form-label">
+ <ng-container i18n>Failure domain type</ng-container>
+ <cd-helper [html]="tooltips.failure_domain">
+ </cd-helper>
+ <span class="required"></span>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="failure_domain"
+ name="failure_domain"
+ formControlName="failure_domain">
+ <option *ngIf="!failureDomains"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let domain of failureDomainKeys"
+ [ngValue]="domain">
+ {{ domain }} ( {{failureDomains[domain].length}} )
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('failure_domain', frm, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <!-- Class -->
+ <div class="form-group row">
+ <label for="device_class"
+ class="cd-col-form-label">
+ <ng-container i18n>Device class</ng-container>
+ <cd-helper [html]="tooltips.device_class">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="device_class"
+ name="device_class"
+ formControlName="device_class">
+ <option ngValue=""
+ i18n>Let Ceph decide</option>
+ <option *ngFor="let deviceClass of devices"
+ [ngValue]="deviceClass">
+ {{ deviceClass }}
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="form"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts
new file mode 100644
index 000000000..2b8c9e5cf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts
@@ -0,0 +1,210 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { CrushRuleService } from '~/app/shared/api/crush-rule.service';
+import { CrushNode } from '~/app/shared/models/crush-node';
+import { CrushRuleConfig } from '~/app/shared/models/crush-rule';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { configureTestBed, FixtureHelper, FormHelper, Mocks } from '~/testing/unit-test-helper';
+import { PoolModule } from '../pool.module';
+import { CrushRuleFormModalComponent } from './crush-rule-form-modal.component';
+
+describe('CrushRuleFormComponent', () => {
+ let component: CrushRuleFormModalComponent;
+ let crushRuleService: CrushRuleService;
+ let fixture: ComponentFixture<CrushRuleFormModalComponent>;
+ let formHelper: FormHelper;
+ let fixtureHelper: FixtureHelper;
+ let data: { names: string[]; nodes: CrushNode[] };
+
+ // Object contains functions to get something
+ const get = {
+ nodeByName: (name: string): CrushNode => data.nodes.find((node) => node.name === name),
+ nodesByNames: (names: string[]): CrushNode[] => names.map(get.nodeByName)
+ };
+
+ // Expects that are used frequently
+ const assert = {
+ failureDomains: (nodes: CrushNode[], types: string[]) => {
+ const expectation = {};
+ types.forEach((type) => (expectation[type] = nodes.filter((node) => node.type === type)));
+ const keys = component.failureDomainKeys;
+ expect(keys).toEqual(types);
+ keys.forEach((key) => {
+ expect(component.failureDomains[key].length).toBe(expectation[key].length);
+ });
+ },
+ formFieldValues: (root: CrushNode, failureDomain: string, device: string) => {
+ expect(component.form.value).toEqual({
+ name: '',
+ root,
+ failure_domain: failureDomain,
+ device_class: device
+ });
+ },
+ valuesOnRootChange: (
+ rootName: string,
+ expectedFailureDomain: string,
+ expectedDevice: string
+ ) => {
+ const node = get.nodeByName(rootName);
+ formHelper.setValue('root', node);
+ assert.formFieldValues(node, expectedFailureDomain, expectedDevice);
+ },
+ creation: (rule: CrushRuleConfig) => {
+ formHelper.setValue('name', rule.name);
+ fixture.detectChanges();
+ component.onSubmit();
+ expect(crushRuleService.create).toHaveBeenCalledWith(rule);
+ }
+ };
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot(), PoolModule],
+ providers: [CrushRuleService, NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CrushRuleFormModalComponent);
+ fixtureHelper = new FixtureHelper(fixture);
+ component = fixture.componentInstance;
+ formHelper = new FormHelper(component.form);
+ crushRuleService = TestBed.inject(CrushRuleService);
+ data = {
+ names: ['rule1', 'rule2'],
+ /**
+ * Create the following test crush map:
+ * > default
+ * --> ssd-host
+ * ----> 3x osd with ssd
+ * --> mix-host
+ * ----> hdd-rack
+ * ------> 2x osd-rack with hdd
+ * ----> ssd-rack
+ * ------> 2x osd-rack with ssd
+ */
+ nodes: Mocks.getCrushMap()
+ };
+ spyOn(crushRuleService, 'getInfo').and.callFake(() => of(data));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('calls listing to get rules on ngInit', () => {
+ expect(crushRuleService.getInfo).toHaveBeenCalled();
+ expect(component.names.length).toBe(2);
+ expect(component.buckets.length).toBe(5);
+ });
+
+ describe('lists', () => {
+ afterEach(() => {
+ // The available buckets should not change
+ expect(component.buckets).toEqual(
+ get.nodesByNames(['default', 'hdd-rack', 'mix-host', 'ssd-host', 'ssd-rack'])
+ );
+ });
+
+ it('has the following lists after init', () => {
+ assert.failureDomains(data.nodes, ['host', 'osd', 'osd-rack', 'rack']); // Not root as root only exist once
+ expect(component.devices).toEqual(['hdd', 'ssd']);
+ });
+
+ it('has the following lists after selection of ssd-host', () => {
+ formHelper.setValue('root', get.nodeByName('ssd-host'));
+ assert.failureDomains(get.nodesByNames(['osd.0', 'osd.1', 'osd.2']), ['osd']); // Not host as it only exist once
+ expect(component.devices).toEqual(['ssd']);
+ });
+
+ it('has the following lists after selection of mix-host', () => {
+ formHelper.setValue('root', get.nodeByName('mix-host'));
+ expect(component.devices).toEqual(['hdd', 'ssd']);
+ assert.failureDomains(
+ get.nodesByNames(['hdd-rack', 'ssd-rack', 'osd2.0', 'osd2.1', 'osd2.0', 'osd2.1']),
+ ['osd-rack', 'rack']
+ );
+ });
+ });
+
+ describe('selection', () => {
+ it('selects the first root after init automatically', () => {
+ assert.formFieldValues(get.nodeByName('default'), 'osd-rack', '');
+ });
+
+ it('should select all values automatically by selecting "ssd-host" as root', () => {
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ });
+
+ it('selects automatically the most common failure domain', () => {
+ // Select mix-host as mix-host has multiple failure domains (osd-rack and rack)
+ assert.valuesOnRootChange('mix-host', 'osd-rack', '');
+ });
+
+ it('should override automatic selections', () => {
+ assert.formFieldValues(get.nodeByName('default'), 'osd-rack', '');
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ assert.valuesOnRootChange('mix-host', 'osd-rack', '');
+ });
+
+ it('should not override manual selections if possible', () => {
+ formHelper.setValue('failure_domain', 'rack', true);
+ formHelper.setValue('device_class', 'ssd', true);
+ assert.valuesOnRootChange('mix-host', 'rack', 'ssd');
+ });
+
+ it('should preselect device by domain selection', () => {
+ formHelper.setValue('failure_domain', 'osd', true);
+ assert.formFieldValues(get.nodeByName('default'), 'osd', 'ssd');
+ });
+ });
+
+ describe('form validation', () => {
+ it(`isn't valid if name is not set`, () => {
+ expect(component.form.invalid).toBeTruthy();
+ formHelper.setValue('name', 'someProfileName');
+ expect(component.form.valid).toBeTruthy();
+ });
+
+ it('sets name invalid', () => {
+ component.names = ['awesomeProfileName'];
+ formHelper.expectErrorChange('name', 'awesomeProfileName', 'uniqueName');
+ formHelper.expectErrorChange('name', 'some invalid text', 'pattern');
+ formHelper.expectErrorChange('name', null, 'required');
+ });
+
+ it(`should show all default form controls`, () => {
+ // name
+ // root (preselected(first root))
+ // failure_domain (preselected=type that is most common)
+ // device_class (preselected=any if multiple or some type if only one device type)
+ fixtureHelper.expectIdElementsVisible(
+ ['name', 'root', 'failure_domain', 'device_class'],
+ true
+ );
+ });
+ });
+
+ describe('submission', () => {
+ beforeEach(() => {
+ const taskWrapper = TestBed.inject(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ spyOn(crushRuleService, 'create').and.stub();
+ });
+
+ it('creates a rule with only required fields', () => {
+ assert.creation(Mocks.getCrushRuleConfig('default-rule', 'default', 'osd-rack'));
+ });
+
+ it('creates a rule with all fields', () => {
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ assert.creation(Mocks.getCrushRuleConfig('ssd-host-rule', 'ssd-host', 'osd', 'ssd'));
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts
new file mode 100644
index 000000000..308b09d72
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts
@@ -0,0 +1,108 @@
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { CrushRuleService } from '~/app/shared/api/crush-rule.service';
+import { CrushNodeSelectionClass } from '~/app/shared/classes/crush.node.selection.class';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CrushNode } from '~/app/shared/models/crush-node';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-crush-rule-form-modal',
+ templateUrl: './crush-rule-form-modal.component.html',
+ styleUrls: ['./crush-rule-form-modal.component.scss']
+})
+export class CrushRuleFormModalComponent extends CrushNodeSelectionClass implements OnInit {
+ @Output()
+ submitAction = new EventEmitter();
+
+ tooltips = this.crushRuleService.formTooltips;
+
+ form: CdFormGroup;
+ names: string[];
+ action: string;
+ resource: string;
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public activeModal: NgbActiveModal,
+ private taskWrapper: TaskWrapperService,
+ private crushRuleService: CrushRuleService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.action = this.actionLabels.CREATE;
+ this.resource = $localize`Crush Rule`;
+ this.createForm();
+ }
+
+ createForm() {
+ this.form = this.formBuilder.group({
+ // name: string
+ name: [
+ '',
+ [
+ Validators.required,
+ Validators.pattern('[A-Za-z0-9_-]+'),
+ CdValidators.custom(
+ 'uniqueName',
+ (value: any) => this.names && this.names.indexOf(value) !== -1
+ )
+ ]
+ ],
+ // root: CrushNode
+ root: null, // Replaced with first root
+ // failure_domain: string
+ failure_domain: '', // Replaced with most common type
+ // device_class: string
+ device_class: '' // Replaced with device type if only one exists beneath domain
+ });
+ }
+
+ ngOnInit() {
+ this.crushRuleService
+ .getInfo()
+ .subscribe(({ names, nodes }: { names: string[]; nodes: CrushNode[] }) => {
+ this.initCrushNodeSelection(
+ nodes,
+ this.form.get('root'),
+ this.form.get('failure_domain'),
+ this.form.get('device_class')
+ );
+ this.names = names;
+ });
+ }
+
+ onSubmit() {
+ if (this.form.invalid) {
+ this.form.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ const rule = _.cloneDeep(this.form.value);
+ rule.root = rule.root.name;
+ if (rule.device_class === '') {
+ delete rule.device_class;
+ }
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('crushRule/create', rule),
+ call: this.crushRuleService.create(rule)
+ })
+ .subscribe({
+ error: () => {
+ this.form.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ this.submitAction.emit(rule);
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html
new file mode 100644
index 000000000..b4b9cd193
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html
@@ -0,0 +1,420 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="name"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ id="name"
+ name="name"
+ class="form-control"
+ placeholder="Name..."
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'pattern')"
+ i18n>The name can only consist of alphanumeric characters, dashes and underscores.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'uniqueName')"
+ i18n>The chosen erasure code profile name is already in use.</span>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="plugin"
+ class="cd-col-form-label">
+ <span class="required"
+ i18n>Plugin</span>
+ <cd-helper [html]="tooltips.plugins[plugin].description">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="plugin"
+ name="plugin"
+ formControlName="plugin">
+ <option *ngIf="!plugins"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let plugin of plugins"
+ [ngValue]="plugin">
+ {{ plugin }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', frm, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="k"
+ class="cd-col-form-label">
+ <span class="required"
+ i18n>Data chunks (k)</span>
+ <cd-helper [html]="tooltips.k">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="number"
+ id="k"
+ name="k"
+ class="form-control"
+ ng-model="$ctrl.erasureCodeProfile.k"
+ placeholder="Data chunks..."
+ formControlName="k"
+ min="2">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('k', frm, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('k', frm, 'min')"
+ i18n>Must be equal to or greater than 2.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('k', frm, 'max')"
+ i18n>Chunks (k+m) have exceeded the available OSDs of {{deviceCount}}.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('k', frm, 'unequal')"
+ i18n>For an equal distribution k has to be a multiple of (k+m)/l.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('k', frm, 'kLowerM')"
+ i18n>K has to be equal to or greater than m in order to recover data correctly through c.</span>
+ <span *ngIf="plugin === 'lrc'"
+ class="form-text text-muted"
+ i18n>Distribution factor: {{lrcMultiK}}</span>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="m"
+ class="cd-col-form-label">
+ <span class="required"
+ i18n>Coding chunks (m)</span>
+ <cd-helper [html]="tooltips.m">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="number"
+ id="m"
+ name="m"
+ class="form-control"
+ placeholder="Coding chunks..."
+ formControlName="m"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('m', frm, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('m', frm, 'min')"
+ i18n>Must be equal to or greater than 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('m', frm, 'max')"
+ i18n>Chunks (k+m) have exceeded the available OSDs of {{deviceCount}}.</span>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="plugin === 'shec'">
+ <label for="c"
+ class="cd-col-form-label">
+ <span class="required"
+ i18n>Durability estimator (c)</span>
+ <cd-helper [html]="tooltips.plugins.shec.c">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="number"
+ id="c"
+ name="c"
+ class="form-control"
+ placeholder="Coding chunks..."
+ formControlName="c"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('c', frm, 'min')"
+ i18n>Must be equal to or greater than 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('c', frm, 'cGreaterM')"
+ i18n>C has to be equal to or lower than m as m defines the amount of chunks that can be used.</span>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="plugin === 'clay'">
+ <label for="d"
+ class="cd-col-form-label">
+ <span class="required"
+ i18n>Helper chunks (d)</span>
+ <cd-helper [html]="tooltips.plugins.clay.d">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input type="number"
+ id="d"
+ name="d"
+ class="form-control"
+ placeholder="Helper chunks..."
+ formControlName="d">
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ id="d-calc-btn"
+ ngbTooltip="Set d manually or use the plugin's default calculation that maximizes d."
+ i18n-ngbTooltip
+ type="button"
+ (click)="toggleDCalc()">
+ <i [ngClass]="dCalc ? icons.unlock : icons.lock"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ <span class="form-text text-muted"
+ *ngIf="dCalc"
+ i18n>D is automatically updated on k and m changes</span>
+ <ng-container
+ *ngIf="!dCalc">
+ <span class="form-text text-muted"
+ *ngIf="getDMin() < getDMax()"
+ i18n>D can be set from {{getDMin()}} to {{getDMax()}}</span>
+ <span class="form-text text-muted"
+ *ngIf="getDMin() === getDMax()"
+ i18n>D can only be set to {{getDMax()}}</span>
+ </ng-container>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('d', frm, 'dMin')"
+ i18n>D has to be greater than k ({{getDMin()}}).</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('d', frm, 'dMax')"
+ i18n>D has to be lower than k + m ({{getDMax()}}).</span>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="plugin === PLUGIN.LRC">
+ <label class="cd-col-form-label"
+ for="l">
+ <span class="required"
+ i18n>Locality (l)</span>
+ <cd-helper [html]="tooltips.plugins.lrc.l">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="number"
+ id="l"
+ name="l"
+ class="form-control"
+ placeholder="Coding chunks..."
+ formControlName="l"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('l', frm, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('l', frm, 'min')"
+ i18n>Must be equal to or greater than 1.</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('l', frm, 'unequal')"
+ i18n>Can't split up chunks (k+m) correctly with the current locality.</span>
+ <span class="form-text text-muted"
+ i18n>Locality groups: {{lrcGroups}}</span>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="crushFailureDomain"
+ class="cd-col-form-label">
+ <ng-container i18n>Crush failure domain</ng-container>
+ <cd-helper [html]="tooltips.crushFailureDomain">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="crushFailureDomain"
+ name="crushFailureDomain"
+ formControlName="crushFailureDomain">
+ <option *ngIf="!failureDomains"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let domain of failureDomainKeys"
+ [ngValue]="domain">
+ {{ domain }} ( {{failureDomains[domain].length}} )
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="plugin === PLUGIN.LRC">
+ <label for="crushLocality"
+ class="cd-col-form-label">
+ <ng-container i18n>Crush Locality</ng-container>
+ <cd-helper [html]="tooltips.plugins.lrc.crushLocality">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="crushLocality"
+ name="crushLocality"
+ formControlName="crushLocality">
+ <option *ngIf="!failureDomains"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngIf="failureDomainKeys.length > 0"
+ ngValue=""
+ i18n>None</option>
+ <option *ngFor="let domain of failureDomainKeys"
+ [ngValue]="domain">
+ {{ domain }} ( {{failureDomains[domain].length}} )
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="PLUGIN.CLAY === plugin">
+ <label for="scalar_mds"
+ class="cd-col-form-label">
+ <ng-container i18n>Scalar mds</ng-container>
+ <cd-helper [html]="tooltips.plugins.clay.scalar_mds">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="scalar_mds"
+ name="scalar_mds"
+ formControlName="scalar_mds">
+ <option *ngFor="let plugin of [PLUGIN.JERASURE, PLUGIN.ISA, PLUGIN.SHEC]"
+ [ngValue]="plugin">
+ {{ plugin }}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="[PLUGIN.JERASURE, PLUGIN.ISA, PLUGIN.CLAY].includes(plugin)">
+ <label for="technique"
+ class="cd-col-form-label">
+ <ng-container i18n>Technique</ng-container>
+ <cd-helper [html]="tooltips.plugins[plugin].technique">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="technique"
+ name="technique"
+ formControlName="technique">
+ <option *ngFor="let technique of techniques"
+ [ngValue]="technique">
+ {{ technique }}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group row"
+ *ngIf="plugin === PLUGIN.JERASURE">
+ <label for="packetSize"
+ class="cd-col-form-label">
+ <ng-container i18n>Packetsize</ng-container>
+ <cd-helper [html]="tooltips.plugins.jerasure.packetSize">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="number"
+ id="packetSize"
+ name="packetSize"
+ class="form-control"
+ placeholder="Packetsize..."
+ formControlName="packetSize"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('packetSize', frm, 'min')"
+ i18n>Must be equal to or greater than 1.</span>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="crushRoot"
+ class="cd-col-form-label">
+ <ng-container i18n>Crush root</ng-container>
+ <cd-helper [html]="tooltips.crushRoot">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="crushRoot"
+ name="crushRoot"
+ formControlName="crushRoot">
+ <option *ngIf="!buckets"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngFor="let bucket of buckets"
+ [ngValue]="bucket">
+ {{ bucket.name }}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="crushDeviceClass"
+ class="cd-col-form-label">
+ <ng-container i18n>Crush device class</ng-container>
+ <cd-helper [html]="tooltips.crushDeviceClass">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="crushDeviceClass"
+ name="crushDeviceClass"
+ formControlName="crushDeviceClass">
+ <option ngValue=""
+ i18n>Let Ceph decide</option>
+ <option *ngFor="let deviceClass of devices"
+ [ngValue]="deviceClass">
+ {{ deviceClass }}
+ </option>
+ </select>
+ <span class="form-text text-muted"
+ i18n>Available OSDs: {{deviceCount}}</span>
+ </div>
+ </div>
+
+ <div class="form-group row">
+ <label for="directory"
+ class="cd-col-form-label">
+ <ng-container i18n>Directory</ng-container>
+ <cd-helper [html]="tooltips.directory">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ id="directory"
+ name="directory"
+ class="form-control"
+ placeholder="Path..."
+ formControlName="directory">
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="form"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.spec.ts
new file mode 100644
index 000000000..7d0331dfe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.spec.ts
@@ -0,0 +1,688 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
+import { CrushNode } from '~/app/shared/models/crush-node';
+import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { configureTestBed, FixtureHelper, FormHelper, Mocks } from '~/testing/unit-test-helper';
+import { PoolModule } from '../pool.module';
+import { ErasureCodeProfileFormModalComponent } from './erasure-code-profile-form-modal.component';
+
+describe('ErasureCodeProfileFormModalComponent', () => {
+ let component: ErasureCodeProfileFormModalComponent;
+ let ecpService: ErasureCodeProfileService;
+ let fixture: ComponentFixture<ErasureCodeProfileFormModalComponent>;
+ let formHelper: FormHelper;
+ let fixtureHelper: FixtureHelper;
+ let data: { plugins: string[]; names: string[]; nodes: CrushNode[] };
+
+ const expectTechnique = (current: string) =>
+ expect(component.form.getValue('technique')).toBe(current);
+
+ const expectTechniques = (techniques: string[], current: string) => {
+ expect(component.techniques).toEqual(techniques);
+ expectTechnique(current);
+ };
+
+ const expectRequiredControls = (controlNames: string[]) => {
+ controlNames.forEach((name) => {
+ const value = component.form.getValue(name);
+ formHelper.expectValid(name);
+ formHelper.expectErrorChange(name, null, 'required');
+ // This way other fields won't fail through getting invalid.
+ formHelper.expectValidChange(name, value);
+ });
+ fixtureHelper.expectIdElementsVisible(controlNames, true);
+ };
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot(), PoolModule],
+ providers: [ErasureCodeProfileService, NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ErasureCodeProfileFormModalComponent);
+ fixtureHelper = new FixtureHelper(fixture);
+ component = fixture.componentInstance;
+ formHelper = new FormHelper(component.form);
+ ecpService = TestBed.inject(ErasureCodeProfileService);
+ data = {
+ plugins: ['isa', 'jerasure', 'shec', 'lrc'],
+ names: ['ecp1', 'ecp2'],
+ /**
+ * Create the following test crush map:
+ * > default
+ * --> ssd-host
+ * ----> 3x osd with ssd
+ * --> mix-host
+ * ----> hdd-rack
+ * ------> 5x osd-rack with hdd
+ * ----> ssd-rack
+ * ------> 5x osd-rack with ssd
+ */
+ nodes: [
+ // Root node
+ Mocks.getCrushNode('default', -1, 'root', 11, [-2, -3]),
+ // SSD host
+ Mocks.getCrushNode('ssd-host', -2, 'host', 1, [1, 0, 2]),
+ Mocks.getCrushNode('osd.0', 0, 'osd', 0, undefined, 'ssd'),
+ Mocks.getCrushNode('osd.1', 1, 'osd', 0, undefined, 'ssd'),
+ Mocks.getCrushNode('osd.2', 2, 'osd', 0, undefined, 'ssd'),
+ // SSD and HDD mixed devices host
+ Mocks.getCrushNode('mix-host', -3, 'host', 1, [-4, -5]),
+ // HDD rack
+ Mocks.getCrushNode('hdd-rack', -4, 'rack', 3, [3, 4, 5, 6, 7]),
+ Mocks.getCrushNode('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'),
+ Mocks.getCrushNode('osd2.1', 4, 'osd-rack', 0, undefined, 'hdd'),
+ Mocks.getCrushNode('osd2.2', 5, 'osd-rack', 0, undefined, 'hdd'),
+ Mocks.getCrushNode('osd2.3', 6, 'osd-rack', 0, undefined, 'hdd'),
+ Mocks.getCrushNode('osd2.4', 7, 'osd-rack', 0, undefined, 'hdd'),
+ // SSD rack
+ Mocks.getCrushNode('ssd-rack', -5, 'rack', 3, [8, 9, 10, 11, 12]),
+ Mocks.getCrushNode('osd3.0', 8, 'osd-rack', 0, undefined, 'ssd'),
+ Mocks.getCrushNode('osd3.1', 9, 'osd-rack', 0, undefined, 'ssd'),
+ Mocks.getCrushNode('osd3.2', 10, 'osd-rack', 0, undefined, 'ssd'),
+ Mocks.getCrushNode('osd3.3', 11, 'osd-rack', 0, undefined, 'ssd'),
+ Mocks.getCrushNode('osd3.4', 12, 'osd-rack', 0, undefined, 'ssd')
+ ]
+ };
+ spyOn(ecpService, 'getInfo').and.callFake(() => of(data));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('calls listing to get ecps on ngInit', () => {
+ expect(ecpService.getInfo).toHaveBeenCalled();
+ expect(component.names.length).toBe(2);
+ });
+
+ describe('form validation', () => {
+ it(`isn't valid if name is not set`, () => {
+ expect(component.form.invalid).toBeTruthy();
+ formHelper.setValue('name', 'someProfileName');
+ expect(component.form.valid).toBeTruthy();
+ });
+
+ it('sets name invalid', () => {
+ component.names = ['awesomeProfileName'];
+ formHelper.expectErrorChange('name', 'awesomeProfileName', 'uniqueName');
+ formHelper.expectErrorChange('name', 'some invalid text', 'pattern');
+ formHelper.expectErrorChange('name', null, 'required');
+ });
+
+ it('sets k to min error', () => {
+ formHelper.expectErrorChange('k', 1, 'min');
+ });
+
+ it('sets m to min error', () => {
+ formHelper.expectErrorChange('m', 0, 'min');
+ });
+
+ it(`should show all default form controls`, () => {
+ const showDefaults = (plugin: string) => {
+ formHelper.setValue('plugin', plugin);
+ fixtureHelper.expectIdElementsVisible(
+ [
+ 'name',
+ 'plugin',
+ 'k',
+ 'm',
+ 'crushFailureDomain',
+ 'crushRoot',
+ 'crushDeviceClass',
+ 'directory'
+ ],
+ true
+ );
+ };
+ showDefaults('jerasure');
+ showDefaults('shec');
+ showDefaults('lrc');
+ showDefaults('isa');
+ });
+
+ it('should change technique to default if not available in other plugin', () => {
+ expectTechnique('reed_sol_van');
+ formHelper.setValue('technique', 'blaum_roth');
+ expectTechnique('blaum_roth');
+ formHelper.setValue('plugin', 'isa');
+ expectTechnique('reed_sol_van');
+ formHelper.setValue('plugin', 'clay');
+ formHelper.expectValidChange('scalar_mds', 'shec');
+ expectTechnique('single');
+ });
+
+ describe(`for 'jerasure' plugin (default)`, () => {
+ it(`requires 'm' and 'k'`, () => {
+ expectRequiredControls(['k', 'm']);
+ });
+
+ it(`should show 'packetSize' and 'technique'`, () => {
+ fixtureHelper.expectIdElementsVisible(['packetSize', 'technique'], true);
+ });
+
+ it('should show available techniques', () => {
+ expectTechniques(
+ [
+ 'reed_sol_van',
+ 'reed_sol_r6_op',
+ 'cauchy_orig',
+ 'cauchy_good',
+ 'liberation',
+ 'blaum_roth',
+ 'liber8tion'
+ ],
+ 'reed_sol_van'
+ );
+ });
+
+ it(`should not show any other plugin specific form control`, () => {
+ fixtureHelper.expectIdElementsVisible(
+ ['c', 'l', 'crushLocality', 'd', 'scalar_mds'],
+ false
+ );
+ });
+
+ it('should not allow "k" to be changed more than possible', () => {
+ formHelper.expectErrorChange('k', 10, 'max');
+ });
+
+ it('should not allow "m" to be changed more than possible', () => {
+ formHelper.expectErrorChange('m', 10, 'max');
+ });
+ });
+
+ describe(`for 'isa' plugin`, () => {
+ beforeEach(() => {
+ formHelper.setValue('plugin', 'isa');
+ });
+
+ it(`does require 'm' and 'k'`, () => {
+ expectRequiredControls(['k', 'm']);
+ });
+
+ it(`should show 'technique'`, () => {
+ fixtureHelper.expectIdElementsVisible(['technique'], true);
+ });
+
+ it('should show available techniques', () => {
+ expectTechniques(['reed_sol_van', 'cauchy'], 'reed_sol_van');
+ });
+
+ it(`should not show any other plugin specific form control`, () => {
+ fixtureHelper.expectIdElementsVisible(
+ ['c', 'l', 'crushLocality', 'packetSize', 'd', 'scalar_mds'],
+ false
+ );
+ });
+
+ it('should not allow "k" to be changed more than possible', () => {
+ formHelper.expectErrorChange('k', 10, 'max');
+ });
+
+ it('should not allow "m" to be changed more than possible', () => {
+ formHelper.expectErrorChange('m', 10, 'max');
+ });
+ });
+
+ describe(`for 'lrc' plugin`, () => {
+ beforeEach(() => {
+ formHelper.setValue('plugin', 'lrc');
+ formHelper.expectValid('k');
+ formHelper.expectValid('l');
+ formHelper.expectValid('m');
+ });
+
+ it(`requires 'm', 'l' and 'k'`, () => {
+ expectRequiredControls(['k', 'm', 'l']);
+ });
+
+ it(`should show 'l' and 'crushLocality'`, () => {
+ fixtureHelper.expectIdElementsVisible(['l', 'crushLocality'], true);
+ });
+
+ it(`should not show any other plugin specific form control`, () => {
+ fixtureHelper.expectIdElementsVisible(
+ ['c', 'packetSize', 'technique', 'd', 'scalar_mds'],
+ false
+ );
+ });
+
+ it('should not allow "k" to be changed more than possible', () => {
+ formHelper.expectErrorChange('k', 10, 'max');
+ });
+
+ it('should not allow "m" to be changed more than possible', () => {
+ formHelper.expectErrorChange('m', 10, 'max');
+ });
+
+ it('should not allow "l" to be changed so that (k+m) is not a multiple of "l"', () => {
+ formHelper.expectErrorChange('l', 4, 'unequal');
+ });
+
+ it('should update validity of k and l on m change', () => {
+ formHelper.expectValidChange('m', 3);
+ formHelper.expectError('k', 'unequal');
+ formHelper.expectError('l', 'unequal');
+ });
+
+ describe('lrc calculation', () => {
+ const expectCorrectCalculation = (
+ k: number,
+ m: number,
+ l: number,
+ failedControl: string[] = []
+ ) => {
+ formHelper.setValue('k', k);
+ formHelper.setValue('m', m);
+ formHelper.setValue('l', l);
+ ['k', 'l'].forEach((name) => {
+ if (failedControl.includes(name)) {
+ formHelper.expectError(name, 'unequal');
+ } else {
+ formHelper.expectValid(name);
+ }
+ });
+ };
+
+ const tests = {
+ kFails: [
+ [2, 1, 1],
+ [2, 2, 1],
+ [3, 1, 1],
+ [3, 2, 1],
+ [3, 1, 2],
+ [3, 3, 1],
+ [3, 3, 3],
+ [4, 1, 1],
+ [4, 2, 1],
+ [4, 2, 2],
+ [4, 3, 1],
+ [4, 4, 1]
+ ],
+ lFails: [
+ [2, 1, 2],
+ [3, 2, 2],
+ [3, 1, 3],
+ [3, 2, 3],
+ [4, 1, 2],
+ [4, 3, 2],
+ [4, 3, 3],
+ [4, 1, 3],
+ [4, 4, 3],
+ [4, 1, 4],
+ [4, 2, 4],
+ [4, 3, 4]
+ ],
+ success: [
+ [2, 2, 2],
+ [2, 2, 4],
+ [3, 3, 2],
+ [3, 3, 6],
+ [4, 2, 3],
+ [4, 2, 6],
+ [4, 4, 2],
+ [4, 4, 8],
+ [4, 4, 4]
+ ]
+ };
+
+ it('tests all cases where k fails', () => {
+ tests.kFails.forEach((testCase) => {
+ expectCorrectCalculation(testCase[0], testCase[1], testCase[2], ['k']);
+ });
+ });
+
+ it('tests all cases where l fails', () => {
+ tests.lFails.forEach((testCase) => {
+ expectCorrectCalculation(testCase[0], testCase[1], testCase[2], ['k', 'l']);
+ });
+ });
+
+ it('tests all cases where everything is valid', () => {
+ tests.success.forEach((testCase) => {
+ expectCorrectCalculation(testCase[0], testCase[1], testCase[2]);
+ });
+ });
+ });
+ });
+
+ describe(`for 'shec' plugin`, () => {
+ beforeEach(() => {
+ formHelper.setValue('plugin', 'shec');
+ formHelper.expectValid('c');
+ formHelper.expectValid('m');
+ formHelper.expectValid('k');
+ });
+
+ it(`does require 'm', 'c' and 'k'`, () => {
+ expectRequiredControls(['k', 'm', 'c']);
+ });
+
+ it(`should not show any other plugin specific form control`, () => {
+ fixtureHelper.expectIdElementsVisible(
+ ['l', 'crushLocality', 'packetSize', 'technique', 'd', 'scalar_mds'],
+ false
+ );
+ });
+
+ it('should make sure that k has to be equal or greater than m', () => {
+ formHelper.expectValidChange('k', 3);
+ formHelper.expectErrorChange('k', 2, 'kLowerM');
+ });
+
+ it('should make sure that c has to be equal or less than m', () => {
+ formHelper.expectValidChange('c', 3);
+ formHelper.expectErrorChange('c', 4, 'cGreaterM');
+ });
+
+ it('should update validity of k and c on m change', () => {
+ formHelper.expectValidChange('m', 5);
+ formHelper.expectError('k', 'kLowerM');
+ formHelper.expectValid('c');
+
+ formHelper.expectValidChange('m', 1);
+ formHelper.expectError('c', 'cGreaterM');
+ formHelper.expectValid('k');
+ });
+ });
+
+ describe(`for 'clay' plugin`, () => {
+ beforeEach(() => {
+ formHelper.setValue('plugin', 'clay');
+ // Through this change d has a valid range from 4 to 7
+ formHelper.expectValidChange('k', 3);
+ formHelper.expectValidChange('m', 5);
+ });
+
+ it(`does require 'm', 'c', 'd', 'scalar_mds' and 'k'`, () => {
+ fixtureHelper.clickElement('#d-calc-btn');
+ expectRequiredControls(['k', 'm', 'd', 'scalar_mds']);
+ });
+
+ it(`should not show any other plugin specific form control`, () => {
+ fixtureHelper.expectIdElementsVisible(['l', 'crushLocality', 'packetSize', 'c'], false);
+ });
+
+ it('should show default values for d and scalar_mds', () => {
+ expect(component.form.getValue('d')).toBe(7); // (k+m-1)
+ expect(component.form.getValue('scalar_mds')).toBe('jerasure');
+ });
+
+ it('should auto change d if auto calculation is enabled (default)', () => {
+ formHelper.expectValidChange('k', 4);
+ expect(component.form.getValue('d')).toBe(8);
+ });
+
+ it('should have specific techniques for scalar_mds jerasure', () => {
+ expectTechniques(
+ ['reed_sol_van', 'reed_sol_r6_op', 'cauchy_orig', 'cauchy_good', 'liber8tion'],
+ 'reed_sol_van'
+ );
+ });
+
+ it('should have specific techniques for scalar_mds isa', () => {
+ formHelper.expectValidChange('scalar_mds', 'isa');
+ expectTechniques(['reed_sol_van', 'cauchy'], 'reed_sol_van');
+ });
+
+ it('should have specific techniques for scalar_mds shec', () => {
+ formHelper.expectValidChange('scalar_mds', 'shec');
+ expectTechniques(['single', 'multiple'], 'single');
+ });
+
+ describe('Validity of d', () => {
+ beforeEach(() => {
+ // Don't automatically change d - the only way to get d invalid
+ fixtureHelper.clickElement('#d-calc-btn');
+ });
+
+ it('should not automatically change d if k or m have been changed', () => {
+ formHelper.expectValidChange('m', 4);
+ formHelper.expectValidChange('k', 5);
+ expect(component.form.getValue('d')).toBe(7);
+ });
+
+ it('should trigger dMin through change of d', () => {
+ formHelper.expectErrorChange('d', 3, 'dMin');
+ });
+
+ it('should trigger dMax through change of d', () => {
+ formHelper.expectErrorChange('d', 8, 'dMax');
+ });
+
+ it('should trigger dMin through change of k and m', () => {
+ formHelper.expectValidChange('m', 2);
+ formHelper.expectValidChange('k', 7);
+ formHelper.expectError('d', 'dMin');
+ });
+
+ it('should trigger dMax through change of m', () => {
+ formHelper.expectValidChange('m', 3);
+ formHelper.expectError('d', 'dMax');
+ });
+
+ it('should remove dMax through change of k', () => {
+ formHelper.expectValidChange('m', 3);
+ formHelper.expectError('d', 'dMax');
+ formHelper.expectValidChange('k', 5);
+ formHelper.expectValid('d');
+ });
+ });
+ });
+ });
+
+ describe('submission', () => {
+ let ecp: ErasureCodeProfile;
+ let submittedEcp: ErasureCodeProfile;
+
+ const testCreation = () => {
+ fixture.detectChanges();
+ component.onSubmit();
+ expect(ecpService.create).toHaveBeenCalledWith(submittedEcp);
+ };
+
+ const ecpChange = (attribute: string, value: string | number) => {
+ ecp[attribute] = value;
+ submittedEcp[attribute] = value;
+ };
+
+ beforeEach(() => {
+ ecp = new ErasureCodeProfile();
+ submittedEcp = new ErasureCodeProfile();
+ submittedEcp['crush-root'] = 'default';
+ submittedEcp['crush-failure-domain'] = 'osd-rack';
+ submittedEcp['packetsize'] = 2048;
+ submittedEcp['technique'] = 'reed_sol_van';
+
+ const taskWrapper = TestBed.inject(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ spyOn(ecpService, 'create').and.stub();
+ });
+
+ describe(`'jerasure' usage`, () => {
+ beforeEach(() => {
+ submittedEcp['plugin'] = 'jerasure';
+ ecpChange('name', 'jerasureProfile');
+ submittedEcp.k = 4;
+ submittedEcp.m = 2;
+ });
+
+ it('should be able to create a profile with only required fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ testCreation();
+ });
+
+ it(`does not create with missing 'k' or invalid form`, () => {
+ ecpChange('k', 0);
+ formHelper.setMultipleValues(ecp, true);
+ component.onSubmit();
+ expect(ecpService.create).not.toHaveBeenCalled();
+ });
+
+ it('should be able to create a profile with m, k, name, directory and packetSize', () => {
+ ecpChange('m', 3);
+ ecpChange('directory', '/different/ecp/path');
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('packetSize', 8192, true);
+ ecpChange('packetsize', 8192);
+ testCreation();
+ });
+
+ it('should not send the profile with unsupported fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('crushLocality', 'osd', true);
+ testCreation();
+ });
+ });
+
+ describe(`'isa' usage`, () => {
+ beforeEach(() => {
+ ecpChange('name', 'isaProfile');
+ ecpChange('plugin', 'isa');
+ submittedEcp.k = 7;
+ submittedEcp.m = 3;
+ delete submittedEcp.packetsize;
+ });
+
+ it('should be able to create a profile with only plugin and name', () => {
+ formHelper.setMultipleValues(ecp, true);
+ testCreation();
+ });
+
+ it('should send profile with plugin, name, failure domain and technique only', () => {
+ ecpChange('technique', 'cauchy');
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('crushFailureDomain', 'osd', true);
+ submittedEcp['crush-failure-domain'] = 'osd';
+ submittedEcp['crush-device-class'] = 'ssd';
+ testCreation();
+ });
+
+ it('should not send the profile with unsupported fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('packetSize', 'osd', true);
+ testCreation();
+ });
+ });
+
+ describe(`'lrc' usage`, () => {
+ beforeEach(() => {
+ ecpChange('name', 'lrcProfile');
+ ecpChange('plugin', 'lrc');
+ submittedEcp.k = 4;
+ submittedEcp.m = 2;
+ submittedEcp.l = 3;
+ delete submittedEcp.packetsize;
+ delete submittedEcp.technique;
+ });
+
+ it('should be able to create a profile with only required fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ testCreation();
+ });
+
+ it('should send profile with all required fields and crush root and locality', () => {
+ ecpChange('l', '6');
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('crushRoot', component.buckets[2], true);
+ submittedEcp['crush-root'] = 'mix-host';
+ formHelper.setValue('crushLocality', 'osd-rack', true);
+ submittedEcp['crush-locality'] = 'osd-rack';
+ testCreation();
+ });
+
+ it('should not send the profile with unsupported fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('c', 4, true);
+ testCreation();
+ });
+ });
+
+ describe(`'shec' usage`, () => {
+ beforeEach(() => {
+ ecpChange('name', 'shecProfile');
+ ecpChange('plugin', 'shec');
+ submittedEcp.k = 4;
+ submittedEcp.m = 3;
+ submittedEcp.c = 2;
+ delete submittedEcp.packetsize;
+ delete submittedEcp.technique;
+ });
+
+ it('should be able to create a profile with only plugin and name', () => {
+ formHelper.setMultipleValues(ecp, true);
+ testCreation();
+ });
+
+ it('should send profile with plugin, name, c and crush device class only', () => {
+ ecpChange('c', '3');
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('crushDeviceClass', 'ssd', true);
+ submittedEcp['crush-device-class'] = 'ssd';
+ testCreation();
+ });
+
+ it('should not send the profile with unsupported fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('l', 8, true);
+ testCreation();
+ });
+ });
+
+ describe(`'clay' usage`, () => {
+ beforeEach(() => {
+ ecpChange('name', 'clayProfile');
+ ecpChange('plugin', 'clay');
+ // Setting expectations
+ submittedEcp.k = 4;
+ submittedEcp.m = 2;
+ submittedEcp.d = 5;
+ submittedEcp.scalar_mds = 'jerasure';
+ delete submittedEcp.packetsize;
+ });
+
+ it('should be able to create a profile with only plugin and name', () => {
+ formHelper.setMultipleValues(ecp, true);
+ testCreation();
+ });
+
+ it('should send profile with a changed d', () => {
+ formHelper.setMultipleValues(ecp, true);
+ ecpChange('d', '5');
+ submittedEcp.d = 5;
+ testCreation();
+ });
+
+ it('should send profile with a changed k which automatically changes d', () => {
+ ecpChange('k', 5);
+ formHelper.setMultipleValues(ecp, true);
+ submittedEcp.d = 6;
+ testCreation();
+ });
+
+ it('should send profile with a changed sclara_mds', () => {
+ ecpChange('scalar_mds', 'shec');
+ formHelper.setMultipleValues(ecp, true);
+ submittedEcp.scalar_mds = 'shec';
+ submittedEcp.technique = 'single';
+ testCreation();
+ });
+
+ it('should not send the profile with unsupported fields', () => {
+ formHelper.setMultipleValues(ecp, true);
+ formHelper.setValue('l', 8, true);
+ testCreation();
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts
new file mode 100644
index 000000000..01f7dcb1e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts
@@ -0,0 +1,459 @@
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
+import { CrushNodeSelectionClass } from '~/app/shared/classes/crush.node.selection.class';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CrushNode } from '~/app/shared/models/crush-node';
+import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-erasure-code-profile-form-modal',
+ templateUrl: './erasure-code-profile-form-modal.component.html',
+ styleUrls: ['./erasure-code-profile-form-modal.component.scss']
+})
+export class ErasureCodeProfileFormModalComponent
+ extends CrushNodeSelectionClass
+ implements OnInit {
+ @Output()
+ submitAction = new EventEmitter();
+
+ tooltips = this.ecpService.formTooltips;
+ PLUGIN = {
+ LRC: 'lrc', // Locally Repairable Erasure Code
+ SHEC: 'shec', // Shingled Erasure Code
+ CLAY: 'clay', // Coupled LAYer
+ JERASURE: 'jerasure', // default
+ ISA: 'isa' // Intel Storage Acceleration
+ };
+ plugin = this.PLUGIN.JERASURE;
+ icons = Icons;
+
+ form: CdFormGroup;
+ plugins: string[];
+ names: string[];
+ techniques: string[];
+ action: string;
+ resource: string;
+ dCalc: boolean;
+ lrcGroups: number;
+ lrcMultiK: number;
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public activeModal: NgbActiveModal,
+ private taskWrapper: TaskWrapperService,
+ private ecpService: ErasureCodeProfileService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.action = this.actionLabels.CREATE;
+ this.resource = $localize`EC Profile`;
+ this.createForm();
+ this.setJerasureDefaults();
+ }
+
+ createForm() {
+ this.form = this.formBuilder.group({
+ name: [
+ null,
+ [
+ Validators.required,
+ Validators.pattern('[A-Za-z0-9_-]+'),
+ CdValidators.custom(
+ 'uniqueName',
+ (value: string) => this.names && this.names.indexOf(value) !== -1
+ )
+ ]
+ ],
+ plugin: [this.PLUGIN.JERASURE, [Validators.required]],
+ k: [
+ 4, // Will be overwritten with plugin defaults
+ [
+ Validators.required,
+ CdValidators.custom('max', () => this.baseValueValidation(true)),
+ CdValidators.custom('unequal', (v: number) => this.lrcDataValidation(v)),
+ CdValidators.custom('kLowerM', (v: number) => this.shecDataValidation(v))
+ ]
+ ],
+ m: [
+ 2, // Will be overwritten with plugin defaults
+ [Validators.required, CdValidators.custom('max', () => this.baseValueValidation())]
+ ],
+ crushFailureDomain: '', // Will be preselected
+ crushRoot: null, // Will be preselected
+ crushDeviceClass: '', // Will be preselected
+ directory: '',
+ // Only for 'jerasure', 'clay' and 'isa' use
+ technique: 'reed_sol_van',
+ // Only for 'jerasure' use
+ packetSize: [2048],
+ // Only for 'lrc' use
+ l: [
+ 3, // Will be overwritten with plugin defaults
+ [
+ Validators.required,
+ CdValidators.custom('unequal', (v: number) => this.lrcLocalityValidation(v))
+ ]
+ ],
+ crushLocality: '', // set to none at the end (same list as for failure domains)
+ // Only for 'shec' use
+ c: [
+ 2, // Will be overwritten with plugin defaults
+ [
+ Validators.required,
+ CdValidators.custom('cGreaterM', (v: number) => this.shecDurabilityValidation(v))
+ ]
+ ],
+ // Only for 'clay' use
+ d: [
+ 5, // Will be overwritten with plugin defaults (k+m-1) = k+1 <= d <= k+m-1
+ [
+ Validators.required,
+ CdValidators.custom('dMin', (v: number) => this.dMinValidation(v)),
+ CdValidators.custom('dMax', (v: number) => this.dMaxValidation(v))
+ ]
+ ],
+ scalar_mds: [this.PLUGIN.JERASURE, [Validators.required]] // jerasure or isa or shec
+ });
+ this.toggleDCalc();
+ this.form.get('k').valueChanges.subscribe(() => this.updateValidityOnChange(['m', 'l', 'd']));
+ this.form
+ .get('m')
+ .valueChanges.subscribe(() => this.updateValidityOnChange(['k', 'l', 'c', 'd']));
+ this.form.get('l').valueChanges.subscribe(() => this.updateValidityOnChange(['k', 'm']));
+ this.form.get('plugin').valueChanges.subscribe((plugin) => this.onPluginChange(plugin));
+ this.form.get('scalar_mds').valueChanges.subscribe(() => this.setClayDefaultsForScalar());
+ }
+
+ private baseValueValidation(dataChunk: boolean = false): boolean {
+ return this.validValidation(() => {
+ return (
+ this.getKMSum() > this.deviceCount &&
+ this.form.getValue('k') > this.form.getValue('m') === dataChunk
+ );
+ });
+ }
+
+ private validValidation(fn: () => boolean, plugin?: string): boolean {
+ if (!this.form || plugin ? this.plugin !== plugin : false) {
+ return false;
+ }
+ return fn();
+ }
+
+ private getKMSum(): number {
+ return this.form.getValue('k') + this.form.getValue('m');
+ }
+
+ private lrcDataValidation(k: number): boolean {
+ return this.validValidation(() => {
+ const m = this.form.getValue('m');
+ const l = this.form.getValue('l');
+ const km = k + m;
+ this.lrcMultiK = k / (km / l);
+ return k % (km / l) !== 0;
+ }, 'lrc');
+ }
+
+ private shecDataValidation(k: number): boolean {
+ return this.validValidation(() => {
+ const m = this.form.getValue('m');
+ return m > k;
+ }, 'shec');
+ }
+
+ private lrcLocalityValidation(l: number) {
+ return this.validValidation(() => {
+ const value = this.getKMSum();
+ this.lrcGroups = l > 0 ? value / l : 0;
+ return l > 0 && value % l !== 0;
+ }, 'lrc');
+ }
+
+ private shecDurabilityValidation(c: number): boolean {
+ return this.validValidation(() => {
+ const m = this.form.getValue('m');
+ return c > m;
+ }, 'shec');
+ }
+
+ private dMinValidation(d: number): boolean {
+ return this.validValidation(() => this.getDMin() > d, 'clay');
+ }
+
+ getDMin(): number {
+ return this.form.getValue('k') + 1;
+ }
+
+ private dMaxValidation(d: number): boolean {
+ return this.validValidation(() => d > this.getDMax(), 'clay');
+ }
+
+ getDMax(): number {
+ const m = this.form.getValue('m');
+ const k = this.form.getValue('k');
+ return k + m - 1;
+ }
+
+ toggleDCalc() {
+ this.dCalc = !this.dCalc;
+ this.form.get('d')[this.dCalc ? 'disable' : 'enable']();
+ this.calculateD();
+ }
+
+ private calculateD() {
+ if (this.plugin !== this.PLUGIN.CLAY || !this.dCalc) {
+ return;
+ }
+ this.form.silentSet('d', this.getDMax());
+ }
+
+ private updateValidityOnChange(names: string[]) {
+ names.forEach((name) => {
+ if (name === 'd') {
+ this.calculateD();
+ }
+ this.form.get(name).updateValueAndValidity({ emitEvent: false });
+ });
+ }
+
+ private onPluginChange(plugin: string) {
+ this.plugin = plugin;
+ if (plugin === this.PLUGIN.JERASURE) {
+ this.setJerasureDefaults();
+ } else if (plugin === this.PLUGIN.LRC) {
+ this.setLrcDefaults();
+ } else if (plugin === this.PLUGIN.ISA) {
+ this.setIsaDefaults();
+ } else if (plugin === this.PLUGIN.SHEC) {
+ this.setShecDefaults();
+ } else if (plugin === this.PLUGIN.CLAY) {
+ this.setClayDefaults();
+ }
+ this.updateValidityOnChange(['m']); // Triggers k, m, c, d and l
+ }
+
+ private setJerasureDefaults() {
+ this.techniques = [
+ 'reed_sol_van',
+ 'reed_sol_r6_op',
+ 'cauchy_orig',
+ 'cauchy_good',
+ 'liberation',
+ 'blaum_roth',
+ 'liber8tion'
+ ];
+ this.setDefaults({
+ k: 4,
+ m: 2,
+ technique: 'reed_sol_van'
+ });
+ }
+
+ private setLrcDefaults() {
+ this.setDefaults({
+ k: 4,
+ m: 2,
+ l: 3
+ });
+ }
+
+ private setIsaDefaults() {
+ /**
+ * Actually k and m are not required - but they will be set to the default values in case
+ * if they are not set, therefore it's fine to mark them as required in order to get
+ * strange values that weren't set.
+ */
+ this.techniques = ['reed_sol_van', 'cauchy'];
+ this.setDefaults({
+ k: 7,
+ m: 3,
+ technique: 'reed_sol_van'
+ });
+ }
+
+ private setShecDefaults() {
+ /**
+ * Actually k, c and m are not required - but they will be set to the default values in case
+ * if they are not set, therefore it's fine to mark them as required in order to get
+ * strange values that weren't set.
+ */
+ this.setDefaults({
+ k: 4,
+ m: 3,
+ c: 2
+ });
+ }
+
+ private setClayDefaults() {
+ /**
+ * Actually d and scalar_mds are not required - but they will be set to show the default values
+ * in case if they are not set, therefore it's fine to mark them as required in order to not get
+ * strange values that weren't set.
+ *
+ * As d would be set to the value k+m-1 for the greatest savings, the form will
+ * automatically update d if the automatic calculation is activated (default).
+ */
+ this.setDefaults({
+ k: 4,
+ m: 2,
+ // d: 5, <- Will be automatically update to 5
+ scalar_mds: this.PLUGIN.JERASURE
+ });
+ this.setClayDefaultsForScalar();
+ }
+
+ private setClayDefaultsForScalar() {
+ const plugin = this.form.getValue('scalar_mds');
+ let defaultTechnique = 'reed_sol_van';
+ if (plugin === this.PLUGIN.JERASURE) {
+ this.techniques = [
+ 'reed_sol_van',
+ 'reed_sol_r6_op',
+ 'cauchy_orig',
+ 'cauchy_good',
+ 'liber8tion'
+ ];
+ } else if (plugin === this.PLUGIN.ISA) {
+ this.techniques = ['reed_sol_van', 'cauchy'];
+ } else {
+ // this.PLUGIN.SHEC
+ defaultTechnique = 'single';
+ this.techniques = ['single', 'multiple'];
+ }
+ this.setDefaults({ technique: defaultTechnique });
+ }
+
+ private setDefaults(defaults: object) {
+ Object.keys(defaults).forEach((controlName) => {
+ const control = this.form.get(controlName);
+ const value = control.value;
+ /**
+ * As k, m, c and l are now set touched and dirty on the beginning, plugin change will
+ * overwrite their values as we can't determine if the user has changed anything.
+ * k and m can have two default values where as l and c can only have one,
+ * so there is no need to overwrite them.
+ */
+ const overwrite =
+ control.pristine ||
+ (controlName === 'technique' && !this.techniques.includes(value)) ||
+ (controlName === 'k' && [4, 7].includes(value)) ||
+ (controlName === 'm' && [2, 3].includes(value));
+ if (overwrite) {
+ control.setValue(defaults[controlName]); // also validates new value
+ } else {
+ control.updateValueAndValidity();
+ }
+ });
+ }
+
+ ngOnInit() {
+ this.ecpService
+ .getInfo()
+ .subscribe(
+ ({
+ plugins,
+ names,
+ directory,
+ nodes
+ }: {
+ plugins: string[];
+ names: string[];
+ directory: string;
+ nodes: CrushNode[];
+ }) => {
+ this.initCrushNodeSelection(
+ nodes,
+ this.form.get('crushRoot'),
+ this.form.get('crushFailureDomain'),
+ this.form.get('crushDeviceClass')
+ );
+ this.plugins = plugins;
+ this.names = names;
+ this.form.silentSet('directory', directory);
+ this.preValidateNumericInputFields();
+ }
+ );
+ }
+
+ /**
+ * This allows k, m, l and c to be validated instantly on change, before the
+ * fields got changed before by the user.
+ */
+ private preValidateNumericInputFields() {
+ const kml = ['k', 'm', 'l', 'c', 'd'].map((name) => this.form.get(name));
+ kml.forEach((control) => {
+ control.markAsTouched();
+ control.markAsDirty();
+ });
+ kml[1].updateValueAndValidity(); // Update validity of k, m, c, d and l
+ }
+
+ onSubmit() {
+ if (this.form.invalid) {
+ this.form.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ const profile = this.createJson();
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('ecp/create', { name: profile.name }),
+ call: this.ecpService.create(profile)
+ })
+ .subscribe({
+ error: () => {
+ this.form.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ this.submitAction.emit(profile);
+ }
+ });
+ }
+
+ private createJson() {
+ const pluginControls = {
+ technique: [this.PLUGIN.ISA, this.PLUGIN.JERASURE, this.PLUGIN.CLAY],
+ packetSize: [this.PLUGIN.JERASURE],
+ l: [this.PLUGIN.LRC],
+ crushLocality: [this.PLUGIN.LRC],
+ c: [this.PLUGIN.SHEC],
+ d: [this.PLUGIN.CLAY],
+ scalar_mds: [this.PLUGIN.CLAY]
+ };
+ const ecp = new ErasureCodeProfile();
+ const plugin = this.form.getValue('plugin');
+ Object.keys(this.form.controls)
+ .filter((name) => {
+ const pluginControl = pluginControls[name];
+ const value = this.form.getValue(name);
+ const usable = (pluginControl && pluginControl.includes(plugin)) || !pluginControl;
+ return usable && value && value !== '';
+ })
+ .forEach((name) => {
+ this.extendJson(name, ecp);
+ });
+ return ecp;
+ }
+
+ private extendJson(name: string, ecp: ErasureCodeProfile) {
+ const differentApiAttributes = {
+ crushFailureDomain: 'crush-failure-domain',
+ crushRoot: 'crush-root',
+ crushDeviceClass: 'crush-device-class',
+ packetSize: 'packetsize',
+ crushLocality: 'crush-locality'
+ };
+ const value = this.form.getValue(name);
+ ecp[differentApiAttributes[name] || name] = name === 'crushRoot' ? value.name : value;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html
new file mode 100644
index 000000000..1b0cd563c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html
@@ -0,0 +1,51 @@
+<ng-container *ngIf="selection"
+ cdTableDetail>
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="pool-details">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value [renderObjects]="true"
+ [data]="poolDetails"
+ [autoReload]="false">
+ </cd-table-key-value>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-details"
+ *ngIf="permissions.grafana.read">
+ <a ngbNavLink
+ i18n>Performance Details</a>
+ <ng-template ngbNavContent>
+ <cd-grafana grafanaPath="ceph-pool-detail?var-pool_name={{selection.pool_name}}"
+ uid="-xyV8KCiz"
+ grafanaStyle="three">
+ </cd-grafana>
+ </ng-template>
+ </li>
+ <li ngbNavItem="configuration"
+ *ngIf="selection.type === 'replicated'">
+ <a ngbNavLink
+ i18n>Configuration</a>
+ <ng-template ngbNavContent>
+ <cd-rbd-configuration-table [data]="selectedPoolConfiguration"></cd-rbd-configuration-table>
+ </ng-template>
+ </li>
+ <li ngbNavItem="cache-tiers-details"
+ *ngIf="selection['tiers']?.length > 0">
+ <a ngbNavLink
+ i18n>Cache Tiers Details</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="cacheTiers"
+ [columns]="cacheTierColumns"
+ [autoSave]="false"
+ columnMode="flex">
+ </cd-table>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts
new file mode 100644
index 000000000..f30f954b5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts
@@ -0,0 +1,171 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ChangeDetectorRef } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { RbdConfigurationListComponent } from '~/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component';
+import { Permissions } from '~/app/shared/models/permissions';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, Mocks, TabHelper } from '~/testing/unit-test-helper';
+import { PoolDetailsComponent } from './pool-details.component';
+
+describe('PoolDetailsComponent', () => {
+ let poolDetailsComponent: PoolDetailsComponent;
+ let fixture: ComponentFixture<PoolDetailsComponent>;
+
+ // Needed because of ChangeDetectionStrategy.OnPush
+ // https://github.com/angular/angular/issues/12313#issuecomment-444623173
+ let changeDetector: ChangeDetectorRef;
+ const detectChanges = () => {
+ poolDetailsComponent.ngOnChanges();
+ changeDetector.detectChanges(); // won't call ngOnChanges on it's own but updates fixture
+ };
+
+ const updatePoolSelection = (selection: any) => {
+ poolDetailsComponent.selection = selection;
+ detectChanges();
+ };
+
+ const currentPoolUpdate = () => {
+ updatePoolSelection(poolDetailsComponent.selection);
+ };
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ NgbNavModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule
+ ],
+ declarations: [PoolDetailsComponent, RbdConfigurationListComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PoolDetailsComponent);
+ // Needed because of ChangeDetectionStrategy.OnPush
+ // https://github.com/angular/angular/issues/12313#issuecomment-444623173
+ changeDetector = fixture.componentRef.injector.get(ChangeDetectorRef);
+ poolDetailsComponent = fixture.componentInstance;
+ poolDetailsComponent.selection = undefined;
+ poolDetailsComponent.permissions = new Permissions({
+ grafana: ['read']
+ });
+ updatePoolSelection({ tiers: [0], pool: 0, pool_name: 'micro_pool' });
+ });
+
+ it('should create', () => {
+ expect(poolDetailsComponent).toBeTruthy();
+ });
+
+ describe('Pool details tabset', () => {
+ it('should recognize a tabset child', () => {
+ detectChanges();
+ const ngbNav = TabHelper.getNgbNav(fixture);
+ expect(ngbNav).toBeDefined();
+ });
+
+ it('should not change the tabs active status when selection is the same as before', () => {
+ const tabs = TabHelper.getNgbNavItems(fixture);
+ expect(tabs[0].active).toBeTruthy();
+ currentPoolUpdate();
+ expect(tabs[0].active).toBeTruthy();
+
+ const ngbNav = TabHelper.getNgbNav(fixture);
+ ngbNav.select(tabs[1].id);
+ expect(tabs[1].active).toBeTruthy();
+ currentPoolUpdate();
+ expect(tabs[1].active).toBeTruthy();
+ });
+
+ it('should filter out cdExecuting, cdIsBinary and all stats', () => {
+ updatePoolSelection({
+ prop1: 1,
+ cdIsBinary: true,
+ prop2: 2,
+ cdExecuting: true,
+ prop3: 3,
+ stats: { anyStat: 3, otherStat: [1, 2, 3] }
+ });
+ const expectedPool = { prop1: 1, prop2: 2, prop3: 3 };
+ expect(poolDetailsComponent.poolDetails).toEqual(expectedPool);
+ });
+
+ describe('Updates of shown data', () => {
+ const expectedChange = (
+ expected: {
+ selectedPoolConfiguration?: object;
+ poolDetails?: object;
+ },
+ newSelection: object,
+ doesNotEqualOld = true
+ ) => {
+ const getData = () => {
+ const data = {};
+ Object.keys(expected).forEach((key) => (data[key] = poolDetailsComponent[key]));
+ return data;
+ };
+ const oldData = getData();
+ updatePoolSelection(newSelection);
+ const newData = getData();
+ if (doesNotEqualOld) {
+ expect(expected).not.toEqual(oldData);
+ } else {
+ expect(expected).toEqual(oldData);
+ }
+ expect(expected).toEqual(newData);
+ };
+
+ it('should update shown data on change', () => {
+ expectedChange(
+ {
+ poolDetails: {
+ pg_num: 256,
+ pg_num_target: 256,
+ pg_placement_num: 256,
+ pg_placement_num_target: 256,
+ pool: 2,
+ pool_name: 'somePool',
+ type: 'replicated',
+ size: 3
+ }
+ },
+ Mocks.getPool('somePool', 2)
+ );
+ });
+
+ it('should not update shown data if no detail has changed on pool refresh', () => {
+ expectedChange(
+ {
+ poolDetails: {
+ pool: 0,
+ pool_name: 'micro_pool',
+ tiers: [0]
+ }
+ },
+ poolDetailsComponent.selection,
+ false
+ );
+ });
+
+ it('should show "Cache Tiers Details" tab if selected pool has "tiers"', () => {
+ const tabsItem = TabHelper.getNgbNavItems(fixture);
+ const tabsText = TabHelper.getTextContents(fixture);
+ expect(poolDetailsComponent.selection['tiers'].length).toBe(1);
+ expect(tabsItem.length).toBe(3);
+ expect(tabsText[2]).toBe('Cache Tiers Details');
+ expect(tabsItem[0].active).toBeTruthy();
+ });
+
+ it('should not show "Cache Tiers Details" tab if selected pool has no "tiers"', () => {
+ updatePoolSelection({ tiers: [] });
+ const tabs = TabHelper.getNgbNavItems(fixture);
+ expect(tabs.length).toEqual(2);
+ expect(tabs[0].active).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts
new file mode 100644
index 000000000..21f3ae971
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts
@@ -0,0 +1,80 @@
+import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
+
+import _ from 'lodash';
+
+import { PoolService } from '~/app/shared/api/pool.service';
+import { CdHelperClass } from '~/app/shared/classes/cd-helper.class';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { RbdConfigurationEntry } from '~/app/shared/models/configuration';
+import { Permissions } from '~/app/shared/models/permissions';
+
+@Component({
+ selector: 'cd-pool-details',
+ templateUrl: './pool-details.component.html',
+ styleUrls: ['./pool-details.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class PoolDetailsComponent implements OnChanges {
+ @Input()
+ cacheTiers: any[];
+ @Input()
+ permissions: Permissions;
+ @Input()
+ selection: any;
+
+ cacheTierColumns: Array<CdTableColumn> = [];
+ // 'stats' won't be shown as the pure stat numbers won't tell the user much,
+ // if they are not converted or used in a chart (like the ones available in the pool listing)
+ omittedPoolAttributes = ['cdExecuting', 'cdIsBinary', 'stats'];
+
+ poolDetails: object;
+ selectedPoolConfiguration: RbdConfigurationEntry[];
+
+ constructor(private poolService: PoolService) {
+ this.cacheTierColumns = [
+ {
+ prop: 'pool_name',
+ name: $localize`Name`,
+ flexGrow: 3
+ },
+ {
+ prop: 'cache_mode',
+ name: $localize`Cache Mode`,
+ flexGrow: 2
+ },
+ {
+ prop: 'cache_min_evict_age',
+ name: $localize`Min Evict Age`,
+ flexGrow: 2
+ },
+ {
+ prop: 'cache_min_flush_age',
+ name: $localize`Min Flush Age`,
+ flexGrow: 2
+ },
+ {
+ prop: 'target_max_bytes',
+ name: $localize`Target Max Bytes`,
+ flexGrow: 2
+ },
+ {
+ prop: 'target_max_objects',
+ name: $localize`Target Max Objects`,
+ flexGrow: 2
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.poolService
+ .getConfiguration(this.selection.pool_name)
+ .subscribe((poolConf: RbdConfigurationEntry[]) => {
+ CdHelperClass.updateChanged(this, { selectedPoolConfiguration: poolConf });
+ });
+ CdHelperClass.updateChanged(this, {
+ poolDetails: _.omit(this.selection, this.omittedPoolAttributes)
+ });
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
new file mode 100644
index 000000000..2c5dc57eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
@@ -0,0 +1,37 @@
+import { Validators } from '@angular/forms';
+
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { Pool } from '../pool';
+
+export class PoolFormData {
+ poolTypes: string[];
+ erasureInfo = false;
+ crushInfo = false;
+ applications: any;
+
+ constructor() {
+ this.poolTypes = ['erasure', 'replicated'];
+ this.applications = {
+ selected: [],
+ default: ['cephfs', 'rbd', 'rgw'],
+ available: [], // Filled during runtime
+ validators: [Validators.pattern('[A-Za-z0-9_]+'), Validators.maxLength(128)],
+ messages: new SelectMessages({
+ empty: $localize`No applications added`,
+ selectionLimit: {
+ text: $localize`Applications limit reached`,
+ tooltip: $localize`A pool can only have up to four applications definitions.`
+ },
+ customValidations: {
+ pattern: $localize`Allowed characters '_a-zA-Z0-9'`,
+ maxlength: $localize`Maximum length is 128 characters`
+ },
+ filter: $localize`Filter or add applications'`,
+ add: $localize`Add application`
+ })
+ };
+ }
+
+ pgs = 1;
+ pool: Pool; // Only available during edit mode
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
new file mode 100644
index 000000000..07a26f9eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
@@ -0,0 +1,609 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="card">
+ <div i18n="form title|Example: Create Pool@@formTitle"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="name"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input id="name"
+ name="name"
+ type="text"
+ class="form-control"
+ placeholder="Name..."
+ i18n-placeholder
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', formDir, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('name', formDir, 'uniqueName')"
+ i18n>The chosen Ceph pool name is already in use.</span>
+ <span *ngIf="form.showError('name', formDir, 'rbdPool')"
+ class="invalid-feedback"
+ i18n>It's not possible to create an RBD pool with '/' in the name.
+ Please change the name or remove 'rbd' from the applications list.</span>
+ <span *ngIf="form.showError('name', formDir, 'pattern')"
+ class="invalid-feedback"
+ i18n>Pool name can only contain letters, numbers, '.', '-', '_' or '/'.</span>
+ </div>
+ </div>
+
+ <!-- Pool type selection -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="poolType"
+ i18n>Pool type</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="poolType"
+ formControlName="poolType"
+ name="poolType">
+ <option ngValue=""
+ i18n>-- Select a pool type --</option>
+ <option *ngFor="let poolType of data.poolTypes"
+ [value]="poolType">
+ {{ poolType }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('poolType', formDir, 'required')"
+ i18n>This field is required!</span>
+ </div>
+ </div>
+
+ <div *ngIf="isReplicated || isErasure">
+ <!-- PG Autoscale Mode -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="pgAutoscaleMode">PG Autoscale</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="pgAutoscaleMode"
+ name="pgAutoscaleMode"
+ formControlName="pgAutoscaleMode">
+ <option *ngFor="let mode of pgAutoscaleModes"
+ [value]="mode">
+ {{ mode }}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Pg number -->
+ <div class="form-group row"
+ *ngIf="form.getValue('pgAutoscaleMode') !== 'on'">
+ <label class="cd-col-form-label required"
+ for="pgNum"
+ i18n>Placement groups</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="pgNum"
+ name="pgNum"
+ formControlName="pgNum"
+ min="1"
+ type="number"
+ (focus)="externalPgChange = false"
+ (blur)="alignPgs()"
+ required>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('pgNum', formDir, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('pgNum', formDir, 'min')"
+ i18n>At least one placement group is needed!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('pgNum', formDir, '34')"
+ i18n>Your cluster can't handle this many PGs. Please recalculate the PG amount needed.</span>
+ <span class="form-text text-muted">
+ <cd-doc section="pgs"
+ docText="Calculation help"
+ i18n-docText></cd-doc>
+ </span>
+ <span class="form-text text-muted"
+ *ngIf="externalPgChange"
+ i18n>The current PGs settings were calculated for you, you
+ should make sure the values suit your needs before submit.</span>
+ </div>
+ </div>
+
+ <!-- Replica Size -->
+ <div class="form-group row"
+ *ngIf="isReplicated">
+ <label class="cd-col-form-label required"
+ for="size"
+ i18n>Replicated size</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="size"
+ [max]="getMaxSize()"
+ [min]="getMinSize()"
+ name="size"
+ type="number"
+ formControlName="size">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('size', formDir)">
+ <ul class="list-inline">
+ <li i18n>Minimum: {{ getMinSize() }}</li>
+ <li i18n>Maximum: {{ getMaxSize() }}</li>
+ </ul>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('size', formDir)"
+ i18n>The size specified is out of range. A value from
+ {{ getMinSize() }} to {{ getMaxSize() }} is usable.</span>
+ <span class="text-warning-dark"
+ *ngIf="form.getValue('size') === 1"
+ i18n>A size of 1 will not create a replication of the
+ object. The 'Replicated size' includes the object itself.</span>
+ </div>
+ </div>
+
+ <!-- Flags -->
+ <div class="form-group row"
+ *ngIf="info.is_all_bluestore && isErasure">
+ <label i18n
+ class="cd-col-form-label">Flags</label>
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="ec-overwrites"
+ formControlName="ecOverwrites">
+ <label class="custom-control-label"
+ for="ec-overwrites"
+ i18n>EC Overwrites</label>
+ </div>
+ </div>
+ </div>
+
+ </div>
+ <!-- Applications -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="applications">Applications</label>
+ <div class="cd-col-form-input">
+ <cd-select-badges id="applications"
+ [customBadges]="true"
+ [customBadgeValidators]="data.applications.validators"
+ [messages]="data.applications.messages"
+ [data]="data.applications.selected"
+ [options]="data.applications.available"
+ [selectionLimit]="4"
+ (selection)="appSelection()">
+ </cd-select-badges>
+ </div>
+ </div>
+
+ <!-- CRUSH -->
+ <div *ngIf="isErasure || isReplicated">
+
+ <legend i18n>CRUSH</legend>
+
+ <!-- Erasure Profile select -->
+ <div class="form-group row"
+ *ngIf="isErasure">
+ <label i18n
+ class="cd-col-form-label"
+ for="erasureProfile">Erasure code profile</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <select class="form-control"
+ id="erasureProfile"
+ name="erasureProfile"
+ formControlName="erasureProfile">
+ <option *ngIf="!ecProfiles"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngIf="ecProfiles && ecProfiles.length === 0"
+ [ngValue]="null"
+ i18n>-- No erasure code profile available --</option>
+ <option *ngIf="ecProfiles && ecProfiles.length > 0"
+ [ngValue]="null"
+ i18n>-- Select an erasure code profile --</option>
+ <option *ngFor="let ecp of ecProfiles"
+ [ngValue]="ecp">
+ {{ ecp.name }}
+ </option>
+ </select>
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ [ngClass]="{'active': data.erasureInfo}"
+ id="ecp-info-button"
+ type="button"
+ (click)="data.erasureInfo = !data.erasureInfo">
+ <i [ngClass]="[icons.questionCircle]"
+ aria-hidden="true"></i>
+ </button>
+ <button class="btn btn-light"
+ type="button"
+ *ngIf="!editing"
+ (click)="addErasureCodeProfile()">
+ <i [ngClass]="[icons.add]"
+ aria-hidden="true"></i>
+ </button>
+ <button class="btn btn-light"
+ type="button"
+ *ngIf="!editing"
+ ngbTooltip="This profile can't be deleted as it is in use."
+ i18n-ngbTooltip
+ triggers="manual"
+ #ecpDeletionBtn="ngbTooltip"
+ (click)="deleteErasureCodeProfile()">
+ <i [ngClass]="[icons.trash]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+ <span class="form-text text-muted"
+ id="ecp-info-block"
+ *ngIf="data.erasureInfo && form.getValue('erasureProfile')">
+ <ul ngbNav
+ #ecpInfoTabs="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem="ecp-info">
+ <a ngbNavLink
+ i18n>Profile</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value [renderObjects]="true"
+ [hideKeys]="['name']"
+ [data]="form.getValue('erasureProfile')"
+ [autoReload]="false">
+ </cd-table-key-value>
+ </ng-template>
+ </li>
+ <li ngbNavItem="used-by-pools">
+ <a ngbNavLink
+ i18n>Used by pools</a>
+ <ng-template ngbNavContent>
+ <ng-template #ecpIsNotUsed>
+ <span i18n>Profile is not in use.</span>
+ </ng-template>
+ <ul *ngIf="ecpUsage; else ecpIsNotUsed">
+ <li *ngFor="let pool of ecpUsage">
+ {{ pool }}
+ </li>
+ </ul>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="ecpInfoTabs"></div>
+ </span>
+ </div>
+ </div>
+
+ <!-- Crush ruleset selection -->
+ <div class="form-group row"
+ *ngIf="isErasure && !editing">
+ <label class="cd-col-form-label"
+ for="crushRule"
+ i18n>Crush ruleset</label>
+ <div class="cd-col-form-input">
+ <span class="form-text text-muted"
+ i18n>A new crush ruleset will be implicitly created.</span>
+ </div>
+ </div>
+ <div class="form-group row"
+ *ngIf="isReplicated || editing">
+ <label class="cd-col-form-label"
+ for="crushRule"
+ i18n>Crush ruleset</label>
+ <div class="cd-col-form-input">
+ <ng-template #noRules>
+ <span class="form-text text-muted">
+ <span i18n>There are no rules.</span>&nbsp;
+ </span>
+ </ng-template>
+ <div *ngIf="current.rules.length > 0; else noRules">
+ <div class="input-group">
+ <select class="form-control"
+ id="crushRule"
+ formControlName="crushRule"
+ name="crushSet">
+ <option [ngValue]="null"
+ i18n>-- Select a crush rule --</option>
+ <option *ngFor="let rule of current.rules"
+ [ngValue]="rule">
+ {{ rule.rule_name }}
+ </option>
+ </select>
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ [ngClass]="{'active': data.crushInfo}"
+ id="crush-info-button"
+ type="button"
+ ngbTooltip="Placement and
+ replication strategies or distribution policies that allow to
+ specify how CRUSH places data replicas."
+ i18n-ngbTooltip
+ (click)="data.crushInfo = !data.crushInfo">
+ <i [ngClass]="[icons.questionCircle]"
+ aria-hidden="true"></i>
+ </button>
+ <button class="btn btn-light"
+ type="button"
+ *ngIf="isReplicated && !editing"
+ (click)="addCrushRule()">
+ <i [ngClass]="[icons.add]"
+ aria-hidden="true"></i>
+ </button>
+ <button class="btn btn-light"
+ *ngIf="isReplicated && !editing"
+ type="button"
+ ngbTooltip="This rule can't be deleted as it is in use."
+ i18n-ngbTooltip
+ triggers="manual"
+ #crushDeletionBtn="ngbTooltip"
+ (click)="deleteCrushRule()">
+ <i [ngClass]="[icons.trash]"
+ aria-hidden="true"></i>
+ </button>
+ </span>
+ </div>
+
+ <div class="form-text text-muted"
+ id="crush-info-block"
+ *ngIf="data.crushInfo && form.getValue('crushRule')">
+ <ul ngbNav
+ #crushInfoTabs="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem="crush-rule-info">
+ <a ngbNavLink
+ i18n>Crush rule</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value [renderObjects]="false"
+ [hideKeys]="['steps', 'ruleset', 'type', 'rule_name']"
+ [data]="form.getValue('crushRule')"
+ [autoReload]="false">
+ </cd-table-key-value>
+ </ng-template>
+ </li>
+ <li ngbNavItem="crush-rule-steps">
+ <a ngbNavLink
+ i18n>Crush steps</a>
+ <ng-template ngbNavContent>
+ <ol>
+ <li *ngFor="let step of form.get('crushRule').value.steps">
+ {{ describeCrushStep(step) }}
+ </li>
+ </ol>
+ </ng-template>
+ </li>
+ <li ngbNavItem="used-by-pools">
+ <a ngbNavLink
+ i18n>Used by pools</a>
+ <ng-template ngbNavContent>
+
+ <ng-template #ruleIsNotUsed>
+ <span i18n>Rule is not in use.</span>
+ </ng-template>
+ <ul *ngIf="crushUsage; else ruleIsNotUsed">
+ <li *ngFor="let pool of crushUsage">
+ {{ pool }}
+ </li>
+ </ul>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="crushInfoTabs"></div>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('crushRule', formDir, 'required')"
+ i18n>This field is required!</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('crushRule', formDir, 'tooFewOsds')"
+ i18n>The rule can't be used in the current cluster as it has
+ too few OSDs to meet the minimum required OSD by this rule.</span>
+ </div>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- Compression -->
+ <div *ngIf="info.is_all_bluestore"
+ formGroupName="compression">
+ <legend i18n>Compression</legend>
+
+ <!-- Compression Mode -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="mode">Mode</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="mode"
+ name="mode"
+ formControlName="mode">
+ <option *ngFor="let mode of info.compression_modes"
+ [value]="mode">
+ {{ mode }}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div *ngIf="hasCompressionEnabled()">
+ <!-- Compression algorithm selection -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="algorithm">Algorithm</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ id="algorithm"
+ name="algorithm"
+ formControlName="algorithm">
+ <option *ngIf="!info.compression_algorithms"
+ ngValue=""
+ i18n>Loading...</option>
+ <option *ngIf="info.compression_algorithms && info.compression_algorithms.length === 0"
+ i18n
+ ngValue="">-- No erasure compression algorithm available --</option>
+ <option *ngFor="let algorithm of info.compression_algorithms"
+ [value]="algorithm">
+ {{ algorithm }}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Compression min blob size -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="minBlobSize">Minimum blob size</label>
+ <div class="cd-col-form-input">
+ <input id="minBlobSize"
+ name="minBlobSize"
+ formControlName="minBlobSize"
+ type="text"
+ min="0"
+ class="form-control"
+ i18n-placeholder
+ placeholder="e.g., 128KiB"
+ defaultUnit="KiB"
+ cdDimlessBinary>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('minBlobSize', formDir, 'min')"
+ i18n>Value should be greater than 0</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('minBlobSize', formDir, 'maximum')"
+ i18n>Value should be less than the maximum blob size</span>
+ </div>
+ </div>
+
+ <!-- Compression max blob size -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="maxBlobSize">Maximum blob size</label>
+ <div class="cd-col-form-input">
+ <input id="maxBlobSize"
+ type="text"
+ min="0"
+ formControlName="maxBlobSize"
+ class="form-control"
+ i18n-placeholder
+ placeholder="e.g., 512KiB"
+ defaultUnit="KiB"
+ cdDimlessBinary>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('maxBlobSize', formDir, 'min')"
+ i18n>Value should be greater than 0</span>
+ <span class="invalid-feedback"
+ *ngIf="form.showError('maxBlobSize', formDir, 'minimum')"
+ i18n>Value should be greater than the minimum blob size</span>
+ </div>
+ </div>
+
+ <!-- Compression ratio -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="ratio">Ratio</label>
+ <div class="cd-col-form-input">
+ <input id="ratio"
+ name="ratio"
+ formControlName="ratio"
+ type="number"
+ min="0"
+ max="1"
+ step="0.1"
+ class="form-control"
+ i18n-placeholder
+ placeholder="Compression ratio">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('ratio', formDir, 'min') || form.showError('ratio', formDir, 'max')"
+ i18n>Value should be between 0.0 and 1.0</span>
+ </div>
+ </div>
+
+ </div>
+ </div>
+
+ <!-- Quotas -->
+ <div>
+ <legend i18n>Quotas</legend>
+
+ <!-- Max Bytes -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="max_bytes">
+ <ng-container i18n>Max bytes</ng-container>
+ <cd-helper>
+ <span i18n>Leave it blank or specify 0 to disable this quota.</span>
+ <br>
+ <span i18n>A valid quota should be greater than 0.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="max_bytes"
+ name="max_bytes"
+ type="text"
+ formControlName="max_bytes"
+ i18n-placeholder
+ placeholder="e.g., 10GiB"
+ defaultUnit="GiB"
+ cdDimlessBinary>
+ </div>
+ </div>
+
+ <!-- Max Objects -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="max_objects">
+ <ng-container i18n>Max objects</ng-container>
+ <cd-helper>
+ <span i18n>Leave it blank or specify 0 to disable this quota.</span>
+ <br>
+ <span i18n>A valid quota should be greater than 0.</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="max_objects"
+ min="0"
+ name="max_objects"
+ type="number"
+ formControlName="max_objects">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('max_objects', formDir, 'min')"
+ i18n>The value should be greater or equal to 0</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Pool configuration -->
+ <div [hidden]="isErasure || data.applications.selected.indexOf('rbd') === -1">
+ <cd-rbd-configuration-form [form]="form"
+ [initializeData]="initializeConfigData"
+ (changes)="currentConfigurationValues = $event()">
+ </cd-rbd-configuration-form>
+ </div>
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="form"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+
+ </div>
+
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
new file mode 100644
index 000000000..1d58a1778
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
@@ -0,0 +1,1466 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AbstractControl } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { ActivatedRoute, Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import {
+ NgbActiveModal,
+ NgbModalModule,
+ NgbModalRef,
+ NgbNavModule
+} from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { DashboardNotFoundError } from '~/app/core/error/error';
+import { ErrorComponent } from '~/app/core/error/error.component';
+import { CrushRuleService } from '~/app/shared/api/crush-rule.service';
+import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { SelectBadgesComponent } from '~/app/shared/components/select-badges/select-badges.component';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { Permission } from '~/app/shared/models/permissions';
+import { PoolFormInfo } from '~/app/shared/models/pool-form-info';
+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 { SharedModule } from '~/app/shared/shared.module';
+import {
+ configureTestBed,
+ FixtureHelper,
+ FormHelper,
+ Mocks,
+ modalServiceShow
+} from '~/testing/unit-test-helper';
+import { Pool } from '../pool';
+import { PoolModule } from '../pool.module';
+import { PoolFormComponent } from './pool-form.component';
+
+describe('PoolFormComponent', () => {
+ let OSDS = 15;
+ let formHelper: FormHelper;
+ let fixtureHelper: FixtureHelper;
+ let component: PoolFormComponent;
+ let fixture: ComponentFixture<PoolFormComponent>;
+ let poolService: PoolService;
+ let form: CdFormGroup;
+ let router: Router;
+ let ecpService: ErasureCodeProfileService;
+ let crushRuleService: CrushRuleService;
+ let poolPermissions: Permission;
+ let authStorageService: AuthStorageService;
+
+ const setPgNum = (pgs: number): AbstractControl => {
+ const control = formHelper.setValue('pgNum', pgs);
+ fixture.debugElement.query(By.css('#pgNum')).nativeElement.dispatchEvent(new Event('blur'));
+ return control;
+ };
+
+ const testPgUpdate = (pgs: number, jump: number, returnValue: number) => {
+ if (pgs) {
+ setPgNum(pgs);
+ }
+ if (jump) {
+ setPgNum(form.getValue('pgNum') + jump);
+ }
+ expect(form.getValue('pgNum')).toBe(returnValue);
+ };
+
+ const expectValidSubmit = (
+ pool: any,
+ taskName = 'pool/create',
+ poolServiceMethod: 'create' | 'update' = 'create'
+ ) => {
+ spyOn(poolService, poolServiceMethod).and.stub();
+ const taskWrapper = TestBed.inject(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ component.submit();
+ expect(poolService[poolServiceMethod]).toHaveBeenCalledWith(pool);
+ expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
+ task: {
+ name: taskName,
+ metadata: {
+ pool_name: pool.pool
+ }
+ },
+ call: undefined // because of stub
+ });
+ };
+
+ let infoReturn: PoolFormInfo;
+ const setInfo = () => {
+ const ecp1 = new ErasureCodeProfile();
+ ecp1.name = 'ecp1';
+ infoReturn = {
+ pool_names: ['someExistingPoolName'],
+ osd_count: OSDS,
+ is_all_bluestore: true,
+ bluestore_compression_algorithm: 'snappy',
+ compression_algorithms: ['snappy'],
+ compression_modes: ['none', 'passive'],
+ crush_rules_replicated: [
+ Mocks.getCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' }),
+ Mocks.getCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' }),
+ Mocks.getCrushRule({ id: 2, min: 1, max: 9, name: 'used_rule', type: 'replicated' })
+ ],
+ crush_rules_erasure: [
+ Mocks.getCrushRule({ id: 3, min: 1, max: 1, name: 'ecp1', type: 'erasure' })
+ ],
+ erasure_code_profiles: [ecp1],
+ pg_autoscale_default_mode: 'off',
+ pg_autoscale_modes: ['off', 'warn', 'on'],
+ used_rules: {
+ used_rule: ['some.pool.uses.it']
+ },
+ used_profiles: {
+ ecp1: ['some.other.pool.uses.it']
+ },
+ nodes: Mocks.generateSimpleCrushMap(3, 5)
+ };
+ };
+
+ const setUpPoolComponent = () => {
+ fixture = TestBed.createComponent(PoolFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ fixtureHelper = new FixtureHelper(fixture);
+ form = component.form;
+ formHelper = new FormHelper(form);
+ };
+
+ const routes: Routes = [{ path: '404', component: ErrorComponent }];
+
+ configureTestBed(
+ {
+ declarations: [ErrorComponent],
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ RouterTestingModule.withRoutes(routes),
+ ToastrModule.forRoot(),
+ NgbNavModule,
+ PoolModule,
+ SharedModule,
+ NgbModalModule
+ ],
+ providers: [
+ ErasureCodeProfileService,
+ NgbActiveModal,
+ SelectBadgesComponent,
+ { provide: ActivatedRoute, useValue: { params: of({ name: 'somePoolName' }) } }
+ ]
+ },
+ [CriticalConfirmationModalComponent]
+ );
+
+ let navigationSpy: jasmine.Spy;
+
+ beforeEach(() => {
+ poolService = TestBed.inject(PoolService);
+ setInfo();
+ spyOn(poolService, 'getInfo').and.callFake(() => of(infoReturn));
+
+ ecpService = TestBed.inject(ErasureCodeProfileService);
+ crushRuleService = TestBed.inject(CrushRuleService);
+
+ router = TestBed.inject(Router);
+ navigationSpy = spyOn(router, 'navigate').and.stub();
+ authStorageService = TestBed.inject(AuthStorageService);
+ spyOn(authStorageService, 'getPermissions').and.callFake(() => ({
+ pool: poolPermissions
+ }));
+ poolPermissions = new Permission(['update', 'delete', 'read', 'create']);
+ setUpPoolComponent();
+
+ component.loadingReady();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('throws error for not allowed users', () => {
+ const expectError = (redirected: boolean) => {
+ navigationSpy.calls.reset();
+ if (redirected) {
+ expect(() => component.authenticate()).toThrowError(DashboardNotFoundError);
+ } else {
+ expect(() => component.authenticate()).not.toThrowError();
+ }
+ };
+
+ beforeEach(() => {
+ poolPermissions = new Permission(['delete']);
+ });
+
+ it('navigates to Dashboard if not allowed', () => {
+ expect(() => component.authenticate()).toThrowError(DashboardNotFoundError);
+ });
+
+ it('throws error if user is not allowed', () => {
+ expectError(true);
+ poolPermissions.read = true;
+ expectError(true);
+ poolPermissions.delete = true;
+ expectError(true);
+ poolPermissions.update = true;
+ expectError(true);
+ component.editing = true;
+ poolPermissions.update = false;
+ poolPermissions.create = true;
+ expectError(true);
+ });
+
+ it('does not throw error for users with right permissions', () => {
+ poolPermissions.read = true;
+ poolPermissions.create = true;
+ expectError(false);
+ component.editing = true;
+ poolPermissions.update = true;
+ expectError(false);
+ poolPermissions.create = false;
+ expectError(false);
+ });
+ });
+
+ describe('pool form validation', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('is invalid at the beginning all sub forms are valid', () => {
+ expect(form.valid).toBeFalsy();
+ ['name', 'poolType', 'pgNum'].forEach((name) => formHelper.expectError(name, 'required'));
+ ['size', 'crushRule', 'erasureProfile', 'ecOverwrites'].forEach((name) =>
+ formHelper.expectValid(name)
+ );
+ expect(component.form.get('compression').valid).toBeTruthy();
+ });
+
+ it('validates name', () => {
+ expect(component.editing).toBeFalsy();
+ formHelper.expectError('name', 'required');
+ formHelper.expectValidChange('name', 'some-name');
+ formHelper.expectValidChange('name', 'name/with/slash');
+ formHelper.expectErrorChange('name', 'someExistingPoolName', 'uniqueName');
+ formHelper.expectErrorChange('name', 'wrong format with spaces', 'pattern');
+ });
+
+ it('should validate with dots in pool name', () => {
+ formHelper.expectValidChange('name', 'pool.default.bar', true);
+ });
+
+ it('validates poolType', () => {
+ formHelper.expectError('poolType', 'required');
+ formHelper.expectValidChange('poolType', 'erasure');
+ formHelper.expectValidChange('poolType', 'replicated');
+ });
+
+ it('validates that pgNum is required creation mode', () => {
+ formHelper.expectError(form.get('pgNum'), 'required');
+ });
+
+ it('validates pgNum in edit mode', () => {
+ component.data.pool = new Pool('test');
+ component.data.pool.pg_num = 16;
+ component.editing = true;
+ component.ngOnInit(); // Switches form into edit mode
+ formHelper.setValue('poolType', 'erasure');
+ fixture.detectChanges();
+ formHelper.expectValid(setPgNum(8));
+ });
+
+ it('is valid if pgNum, poolType and name are valid', () => {
+ formHelper.setValue('name', 'some-name');
+ formHelper.setValue('poolType', 'erasure');
+ fixture.detectChanges();
+ setPgNum(1);
+ expect(form.valid).toBeTruthy();
+ });
+
+ it('validates crushRule with multiple crush rules', () => {
+ formHelper.expectValidChange('poolType', 'replicated');
+ form.get('crushRule').updateValueAndValidity();
+ formHelper.expectError('crushRule', 'required'); // As multiple rules exist
+ formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds');
+ });
+
+ it('validates crushRule with no crush rules', () => {
+ infoReturn.crush_rules_replicated = [];
+ setUpPoolComponent();
+ formHelper.expectValidChange('poolType', 'replicated');
+ formHelper.expectValid('crushRule');
+ });
+
+ it('validates size', () => {
+ component.info.nodes = Mocks.getCrushMap();
+ formHelper.setValue('poolType', 'replicated');
+ formHelper.expectValid('size');
+ formHelper.setValue('crushRule', Mocks.getCrushRule({ min: 2, max: 6 })); // 3 OSDs usable
+ formHelper.expectErrorChange('size', 1, 'min');
+ formHelper.expectErrorChange('size', 4, 'max'); // More than usable
+ formHelper.expectValidChange('size', 3);
+
+ formHelper.setValue(
+ 'crushRule',
+ Mocks.getCrushRule({ min: 1, max: 2, failureDomain: 'osd-rack' }) // 4 OSDs usable
+ );
+ formHelper.expectErrorChange('size', 4, 'max'); // More than rule allows
+ formHelper.expectValidChange('size', 2);
+ });
+
+ it('validates if warning is displayed when size is 1', () => {
+ formHelper.setValue('poolType', 'replicated');
+ formHelper.expectValid('size');
+
+ formHelper.setValue('size', 1, true);
+ expect(fixtureHelper.getElementByCss('#size ~ .text-warning-dark')).toBeTruthy();
+
+ formHelper.setValue('size', 2, true);
+ expect(fixtureHelper.getElementByCss('#size ~ .text-warning-dark')).toBeFalsy();
+ });
+
+ it('validates compression mode default value', () => {
+ expect(form.getValue('mode')).toBe('none');
+ });
+
+ it('validate quotas', () => {
+ formHelper.expectValid('max_bytes');
+ formHelper.expectValid('max_objects');
+ formHelper.expectValidChange('max_bytes', '10 Gib');
+ formHelper.expectValidChange('max_bytes', '');
+ formHelper.expectValidChange('max_objects', '');
+ formHelper.expectErrorChange('max_objects', -1, 'min');
+ });
+
+ describe('compression form', () => {
+ beforeEach(() => {
+ formHelper.setValue('poolType', 'replicated');
+ formHelper.setValue('mode', 'passive');
+ });
+
+ it('is valid', () => {
+ expect(component.form.get('compression').valid).toBeTruthy();
+ });
+
+ it('validates minBlobSize to be only valid between 0 and maxBlobSize', () => {
+ formHelper.expectErrorChange('minBlobSize', -1, 'min');
+ formHelper.expectValidChange('minBlobSize', 0);
+ formHelper.setValue('maxBlobSize', '2 KiB');
+ formHelper.expectErrorChange('minBlobSize', '3 KiB', 'maximum');
+ formHelper.expectValidChange('minBlobSize', '1.9 KiB');
+ });
+
+ it('validates minBlobSize converts numbers', () => {
+ const control = formHelper.setValue('minBlobSize', '1');
+ fixture.detectChanges();
+ formHelper.expectValid(control);
+ expect(control.value).toBe('1 KiB');
+ });
+
+ it('validates maxBlobSize to be only valid bigger than minBlobSize', () => {
+ formHelper.expectErrorChange('maxBlobSize', -1, 'min');
+ formHelper.setValue('minBlobSize', '1 KiB');
+ formHelper.expectErrorChange('maxBlobSize', '0.5 KiB', 'minimum');
+ formHelper.expectValidChange('maxBlobSize', '1.5 KiB');
+ });
+
+ it('s valid to only use one blob size', () => {
+ formHelper.expectValid(formHelper.setValue('minBlobSize', '1 KiB'));
+ formHelper.expectValid(formHelper.setValue('maxBlobSize', ''));
+ formHelper.expectValid(formHelper.setValue('minBlobSize', ''));
+ formHelper.expectValid(formHelper.setValue('maxBlobSize', '1 KiB'));
+ });
+
+ it('dismisses any size error if one of the blob sizes is changed into a valid state', () => {
+ const min = formHelper.setValue('minBlobSize', '10 KiB');
+ const max = formHelper.setValue('maxBlobSize', '1 KiB');
+ fixture.detectChanges();
+ max.setValue('');
+ formHelper.expectValid(min);
+ formHelper.expectValid(max);
+ max.setValue('1 KiB');
+ fixture.detectChanges();
+ min.setValue('0.5 KiB');
+ formHelper.expectValid(min);
+ formHelper.expectValid(max);
+ });
+
+ it('validates maxBlobSize converts numbers', () => {
+ const control = formHelper.setValue('maxBlobSize', '2');
+ fixture.detectChanges();
+ expect(control.value).toBe('2 KiB');
+ });
+
+ it('validates that odd size validator works as expected', () => {
+ const odd = (min: string, max: string) => component['oddBlobSize'](min, max);
+ expect(odd('10', '8')).toBe(true);
+ expect(odd('8', '-')).toBe(false);
+ expect(odd('8', '10')).toBe(false);
+ expect(odd(null, '8')).toBe(false);
+ expect(odd('10', '')).toBe(false);
+ expect(odd('10', null)).toBe(false);
+ expect(odd(null, null)).toBe(false);
+ });
+
+ it('validates ratio to be only valid between 0 and 1', () => {
+ formHelper.expectValid('ratio');
+ formHelper.expectErrorChange('ratio', -0.1, 'min');
+ formHelper.expectValidChange('ratio', 0);
+ formHelper.expectValidChange('ratio', 1);
+ formHelper.expectErrorChange('ratio', 1.1, 'max');
+ });
+ });
+
+ it('validates application metadata name', () => {
+ formHelper.setValue('poolType', 'replicated');
+ fixture.detectChanges();
+ const selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
+ .componentInstance;
+ const control = selectBadges.cdSelect.filter;
+ formHelper.expectValid(control);
+ control.setValue('?');
+ formHelper.expectError(control, 'pattern');
+ control.setValue('Ab3_');
+ formHelper.expectValid(control);
+ control.setValue('a'.repeat(129));
+ formHelper.expectError(control, 'maxlength');
+ });
+ });
+
+ describe('pool type changes', () => {
+ beforeEach(() => {
+ component.ngOnInit();
+ });
+
+ it('should have a default replicated size of 3', () => {
+ formHelper.setValue('poolType', 'replicated');
+ expect(form.getValue('size')).toBe(3);
+ });
+
+ describe('replicatedRuleChange', () => {
+ beforeEach(() => {
+ formHelper.setValue('poolType', 'replicated');
+ formHelper.setValue('size', 99);
+ });
+
+ it('should not set size if a replicated pool is not set', () => {
+ formHelper.setValue('poolType', 'erasure');
+ expect(form.getValue('size')).toBe(99);
+ formHelper.setValue('crushRule', component.info.crush_rules_replicated[1]);
+ expect(form.getValue('size')).toBe(99);
+ });
+
+ it('should set size to maximum if size exceeds maximum', () => {
+ formHelper.setValue('crushRule', component.info.crush_rules_replicated[0]);
+ expect(form.getValue('size')).toBe(4);
+ });
+
+ it('should set size to minimum if size is lower than minimum', () => {
+ formHelper.setValue('size', -1);
+ formHelper.setValue('crushRule', component.info.crush_rules_replicated[0]);
+ expect(form.getValue('size')).toBe(2);
+ });
+ });
+
+ describe('rulesChange', () => {
+ it('has no effect if info is not there', () => {
+ delete component.info;
+ formHelper.setValue('poolType', 'replicated');
+ expect(component.current.rules).toEqual([]);
+ });
+
+ it('has no effect if pool type is not set', () => {
+ component['poolTypeChange']('');
+ expect(component.current.rules).toEqual([]);
+ });
+
+ it('shows all replicated rules when pool type is "replicated"', () => {
+ formHelper.setValue('poolType', 'replicated');
+ expect(component.current.rules).toEqual(component.info.crush_rules_replicated);
+ expect(component.current.rules.length).toBe(3);
+ });
+
+ it('shows all erasure code rules when pool type is "erasure"', () => {
+ formHelper.setValue('poolType', 'erasure');
+ expect(component.current.rules).toEqual(component.info.crush_rules_erasure);
+ expect(component.current.rules.length).toBe(1);
+ });
+
+ it('disables rule field if only one rule exists which is used in the disabled field', () => {
+ infoReturn.crush_rules_replicated = [
+ Mocks.getCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' })
+ ];
+ setUpPoolComponent();
+ formHelper.setValue('poolType', 'replicated');
+ const control = form.get('crushRule');
+ expect(control.value).toEqual(component.info.crush_rules_replicated[0]);
+ expect(control.disabled).toBe(true);
+ });
+
+ it('does not select the first rule if more than one exist', () => {
+ formHelper.setValue('poolType', 'replicated');
+ const control = form.get('crushRule');
+ expect(control.value).toEqual(null);
+ expect(control.disabled).toBe(false);
+ });
+
+ it('changing between both pool types will not forget the crush rule selection', () => {
+ formHelper.setValue('poolType', 'replicated');
+ const control = form.get('crushRule');
+ const currentRule = component.info.crush_rules_replicated[0];
+ control.setValue(currentRule);
+ formHelper.setValue('poolType', 'erasure');
+ formHelper.setValue('poolType', 'replicated');
+ expect(control.value).toEqual(currentRule);
+ });
+ });
+ });
+
+ describe('getMaxSize and getMinSize', () => {
+ const setCrushRule = ({ min, max }: { min?: number; max?: number }) => {
+ formHelper.setValue('crushRule', Mocks.getCrushRule({ min, max }));
+ };
+
+ it('returns 0 if osd count is 0', () => {
+ component.info.osd_count = 0;
+ expect(component.getMinSize()).toBe(0);
+ expect(component.getMaxSize()).toBe(0);
+ });
+
+ it('returns 0 if info is not there', () => {
+ delete component.info;
+ expect(component.getMinSize()).toBe(0);
+ expect(component.getMaxSize()).toBe(0);
+ });
+
+ it('returns minimum and maximum of rule', () => {
+ setCrushRule({ min: 2, max: 6 });
+ expect(component.getMinSize()).toBe(2);
+ expect(component.getMaxSize()).toBe(6);
+ });
+
+ it('returns 1 as minimum and 3 as maximum if no crush rule is available', () => {
+ expect(component.getMinSize()).toBe(1);
+ expect(component.getMaxSize()).toBe(3);
+ });
+
+ it('returns the osd count as maximum if the rule maximum exceeds it', () => {
+ setCrushRule({ max: 100 });
+ expect(component.getMaxSize()).toBe(15);
+ });
+
+ it('should return the osd count as minimum if its lower the the rule minimum', () => {
+ setCrushRule({ min: 20 });
+ expect(component.getMinSize()).toBe(20);
+ const control = form.get('crushRule');
+ expect(control.invalid).toBe(true);
+ formHelper.expectError(control, 'tooFewOsds');
+ });
+
+ it('should get the right maximum if the device type is defined', () => {
+ formHelper.setValue(
+ 'crushRule',
+ Mocks.getCrushRule({ min: 1, max: 5, itemName: 'default~ssd' })
+ );
+ expect(form.getValue('crushRule').usable_size).toBe(5);
+ });
+ });
+
+ describe('application metadata', () => {
+ let selectBadges: SelectBadgesComponent;
+
+ const testAddApp = (app?: string, result?: string[]) => {
+ selectBadges.cdSelect.filter.setValue(app);
+ selectBadges.cdSelect.updateFilter();
+ selectBadges.cdSelect.selectOption();
+ expect(component.data.applications.selected).toEqual(result);
+ };
+
+ const testRemoveApp = (app: string, result: string[]) => {
+ selectBadges.cdSelect.removeItem(app);
+ expect(component.data.applications.selected).toEqual(result);
+ };
+
+ const setCurrentApps = (apps: string[]) => {
+ component.data.applications.selected = apps;
+ fixture.detectChanges();
+ selectBadges.cdSelect.ngOnInit();
+ return apps;
+ };
+
+ beforeEach(() => {
+ formHelper.setValue('poolType', 'replicated');
+ fixture.detectChanges();
+ selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
+ .componentInstance;
+ });
+
+ it('adds all predefined and a custom applications to the application metadata array', () => {
+ testAddApp('g', ['rgw']);
+ testAddApp('b', ['rbd', 'rgw']);
+ testAddApp('c', ['cephfs', 'rbd', 'rgw']);
+ testAddApp('ownApp', ['cephfs', 'ownApp', 'rbd', 'rgw']);
+ });
+
+ it('only allows 4 apps to be added to the array', () => {
+ const apps = setCurrentApps(['d', 'c', 'b', 'a']);
+ testAddApp('e', apps);
+ });
+
+ it('can remove apps', () => {
+ setCurrentApps(['a', 'b', 'c', 'd']);
+ testRemoveApp('c', ['a', 'b', 'd']);
+ testRemoveApp('a', ['b', 'd']);
+ testRemoveApp('d', ['b']);
+ testRemoveApp('b', []);
+ });
+
+ it('does not remove any app that is not in the array', () => {
+ const apps = ['a', 'b', 'c', 'd'];
+ setCurrentApps(apps);
+ testRemoveApp('e', apps);
+ testRemoveApp('0', apps);
+ });
+ });
+
+ describe('pg number changes', () => {
+ beforeEach(() => {
+ formHelper.setValue('crushRule', {
+ min_size: 1,
+ max_size: 20
+ });
+ formHelper.setValue('poolType', 'erasure');
+ fixture.detectChanges();
+ setPgNum(256);
+ });
+
+ it('updates by value', () => {
+ testPgUpdate(10, undefined, 8);
+ testPgUpdate(22, undefined, 16);
+ testPgUpdate(26, undefined, 32);
+ testPgUpdate(200, undefined, 256);
+ testPgUpdate(300, undefined, 256);
+ testPgUpdate(350, undefined, 256);
+ });
+
+ it('updates by jump -> a magnitude of the power of 2', () => {
+ testPgUpdate(undefined, 1, 512);
+ testPgUpdate(undefined, -1, 256);
+ });
+
+ it('returns 1 as minimum for false numbers', () => {
+ testPgUpdate(-26, undefined, 1);
+ testPgUpdate(0, undefined, 1);
+ testPgUpdate(0, -1, 1);
+ testPgUpdate(undefined, -20, 1);
+ });
+
+ it('changes the value and than jumps', () => {
+ testPgUpdate(230, 1, 512);
+ testPgUpdate(3500, -1, 2048);
+ });
+
+ describe('pg power jump', () => {
+ it('should jump correctly at the beginning', () => {
+ testPgUpdate(1, -1, 1);
+ testPgUpdate(1, 1, 2);
+ testPgUpdate(2, -1, 1);
+ testPgUpdate(2, 1, 4);
+ testPgUpdate(4, -1, 2);
+ testPgUpdate(4, 1, 8);
+ testPgUpdate(4, 1, 8);
+ });
+
+ it('increments pg power if difference to the current number is 1', () => {
+ testPgUpdate(undefined, 1, 512);
+ testPgUpdate(undefined, 1, 1024);
+ testPgUpdate(undefined, 1, 2048);
+ testPgUpdate(undefined, 1, 4096);
+ });
+
+ it('decrements pg power if difference to the current number is -1', () => {
+ testPgUpdate(undefined, -1, 128);
+ testPgUpdate(undefined, -1, 64);
+ testPgUpdate(undefined, -1, 32);
+ testPgUpdate(undefined, -1, 16);
+ testPgUpdate(undefined, -1, 8);
+ });
+ });
+
+ describe('pgCalc', () => {
+ const PGS = 1;
+ OSDS = 8;
+
+ const getValidCase = () => ({
+ type: 'replicated',
+ osds: OSDS,
+ size: 4,
+ ecp: {
+ k: 2,
+ m: 2
+ },
+ expected: 256
+ });
+
+ const testPgCalc = ({ type, osds, size, ecp, expected }: Record<string, any>) => {
+ component.info.osd_count = osds;
+ formHelper.setValue('poolType', type);
+ if (type === 'replicated') {
+ formHelper.setValue('size', size);
+ } else {
+ formHelper.setValue('erasureProfile', ecp);
+ }
+ expect(form.getValue('pgNum')).toBe(expected);
+ expect(component.externalPgChange).toBe(PGS !== expected);
+ };
+
+ beforeEach(() => {
+ setPgNum(PGS);
+ });
+
+ it('does not change anything if type is not valid', () => {
+ const test = getValidCase();
+ test.type = '';
+ test.expected = PGS;
+ testPgCalc(test);
+ });
+
+ it('does not change anything if ecp is not valid', () => {
+ const test = getValidCase();
+ test.expected = PGS;
+ test.type = 'erasure';
+ test.ecp = null;
+ testPgCalc(test);
+ });
+
+ it('calculates some replicated values', () => {
+ const test = getValidCase();
+ testPgCalc(test);
+ test.osds = 16;
+ test.expected = 512;
+ testPgCalc(test);
+ test.osds = 8;
+ test.size = 8;
+ test.expected = 128;
+ testPgCalc(test);
+ });
+
+ it('calculates erasure code values even if selection is disabled', () => {
+ component['initEcp']([{ k: 2, m: 2, name: 'bla', plugin: '', technique: '' }]);
+ const test = getValidCase();
+ test.type = 'erasure';
+ testPgCalc(test);
+ expect(form.get('erasureProfile').disabled).toBeTruthy();
+ });
+
+ it('calculates some erasure code values', () => {
+ const test = getValidCase();
+ test.type = 'erasure';
+ testPgCalc(test);
+ test.osds = 16;
+ test.ecp.m = 5;
+ test.expected = 256;
+ testPgCalc(test);
+ test.ecp.k = 5;
+ test.expected = 128;
+ testPgCalc(test);
+ });
+
+ it('should not change a manual set pg number', () => {
+ form.get('pgNum').markAsDirty();
+ const test = getValidCase();
+ test.expected = PGS;
+ testPgCalc(test);
+ });
+ });
+ });
+
+ describe('crushRule', () => {
+ const selectRuleByIndex = (n: number) => {
+ formHelper.setValue('crushRule', component.info.crush_rules_replicated[n]);
+ };
+
+ beforeEach(() => {
+ formHelper.setValue('poolType', 'replicated');
+ selectRuleByIndex(0);
+ fixture.detectChanges();
+ });
+
+ it('should select the newly created rule', () => {
+ expect(form.getValue('crushRule').rule_name).toBe('rep1');
+ const name = 'awesomeRule';
+ spyOn(TestBed.inject(ModalService), 'show').and.callFake(() => {
+ return {
+ componentInstance: {
+ submitAction: of({ name })
+ }
+ };
+ });
+ infoReturn.crush_rules_replicated.push(Mocks.getCrushRule({ id: 8, name }));
+ component.addCrushRule();
+ expect(form.getValue('crushRule').rule_name).toBe(name);
+ });
+
+ it('should not show info per default', () => {
+ fixtureHelper.expectElementVisible('#crushRule', true);
+ fixtureHelper.expectElementVisible('#crush-info-block', false);
+ });
+
+ it('should show info if the info button is clicked', () => {
+ const infoButton = fixture.debugElement.query(By.css('#crush-info-button'));
+ infoButton.triggerEventHandler('click', null);
+ expect(component.data.crushInfo).toBeTruthy();
+ fixture.detectChanges();
+ expect(infoButton.classes['active']).toBeTruthy();
+ fixtureHelper.expectIdElementsVisible(['crushRule', 'crush-info-block'], true);
+ });
+
+ it('should know which rules are in use', () => {
+ selectRuleByIndex(2);
+ expect(component.crushUsage).toEqual(['some.pool.uses.it']);
+ });
+
+ describe('crush rule deletion', () => {
+ let taskWrapper: TaskWrapperService;
+ let deletion: CriticalConfirmationModalComponent;
+ let deleteSpy: jasmine.Spy;
+ let modalSpy: jasmine.Spy;
+
+ const callDeletion = () => {
+ component.deleteCrushRule();
+ deletion.submitActionObservable();
+ };
+
+ const callDeletionWithRuleByIndex = (index: number) => {
+ deleteSpy.calls.reset();
+ selectRuleByIndex(index);
+ callDeletion();
+ };
+
+ const expectSuccessfulDeletion = (name: string) => {
+ expect(crushRuleService.delete).toHaveBeenCalledWith(name);
+ expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ task: {
+ name: 'crushRule/delete',
+ metadata: {
+ name: name
+ }
+ }
+ })
+ );
+ };
+
+ beforeEach(() => {
+ modalSpy = spyOn(TestBed.inject(ModalService), 'show').and.callFake(
+ (deletionClass: any, initialState: any) => {
+ deletion = Object.assign(new deletionClass(), initialState);
+ return {
+ componentInstance: deletion
+ };
+ }
+ );
+ deleteSpy = spyOn(crushRuleService, 'delete').and.callFake((name: string) => {
+ const rules = infoReturn.crush_rules_replicated;
+ const index = _.findIndex(rules, (rule) => rule.rule_name === name);
+ rules.splice(index, 1);
+ return of(undefined);
+ });
+ taskWrapper = TestBed.inject(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ });
+
+ describe('with unused rule', () => {
+ beforeEach(() => {
+ callDeletionWithRuleByIndex(0);
+ });
+
+ it('should have called delete', () => {
+ expectSuccessfulDeletion('rep1');
+ });
+
+ it('should not open the tooltip nor the crush info', () => {
+ expect(component.crushDeletionBtn.isOpen()).toBe(false);
+ expect(component.data.crushInfo).toBe(false);
+ });
+
+ it('should reload the rules after deletion', () => {
+ const expected = infoReturn.crush_rules_replicated;
+ const currentRules = component.current.rules;
+ expect(currentRules.length).toBe(expected.length);
+ expect(currentRules).toEqual(expected);
+ });
+ });
+
+ describe('rule in use', () => {
+ beforeEach(() => {
+ spyOn(global, 'setTimeout').and.callFake((fn: Function) => fn());
+ deleteSpy.calls.reset();
+ selectRuleByIndex(2);
+ component.deleteCrushRule();
+ });
+
+ it('should not have called delete and opened the tooltip', () => {
+ expect(crushRuleService.delete).not.toHaveBeenCalled();
+ expect(component.crushDeletionBtn.isOpen()).toBe(true);
+ expect(component.data.crushInfo).toBe(true);
+ });
+
+ it('should hide the tooltip when clicking on delete again', () => {
+ component.deleteCrushRule();
+ expect(component.crushDeletionBtn.isOpen()).toBe(false);
+ });
+
+ it('should hide the tooltip when clicking on add', () => {
+ modalSpy.and.callFake((): any => ({
+ componentInstance: {
+ submitAction: of('someRule')
+ }
+ }));
+ component.addCrushRule();
+ expect(component.crushDeletionBtn.isOpen()).toBe(false);
+ });
+
+ it('should hide the tooltip when changing the crush rule', () => {
+ selectRuleByIndex(0);
+ expect(component.crushDeletionBtn.isOpen()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('erasure code profile', () => {
+ const setSelectedEcp = (name: string) => {
+ formHelper.setValue('erasureProfile', { name: name });
+ };
+
+ beforeEach(() => {
+ formHelper.setValue('poolType', 'erasure');
+ fixture.detectChanges();
+ });
+
+ it('should not show info per default', () => {
+ fixtureHelper.expectElementVisible('#erasureProfile', true);
+ fixtureHelper.expectElementVisible('#ecp-info-block', false);
+ });
+
+ it('should show info if the info button is clicked', () => {
+ const infoButton = fixture.debugElement.query(By.css('#ecp-info-button'));
+ infoButton.triggerEventHandler('click', null);
+ expect(component.data.erasureInfo).toBeTruthy();
+ fixture.detectChanges();
+ expect(infoButton.classes['active']).toBeTruthy();
+ fixtureHelper.expectIdElementsVisible(['erasureProfile', 'ecp-info-block'], true);
+ });
+
+ it('should select the newly created profile', () => {
+ spyOn(ecpService, 'list').and.callFake(() => of(infoReturn.erasure_code_profiles));
+ expect(form.getValue('erasureProfile').name).toBe('ecp1');
+ const name = 'awesomeProfile';
+ spyOn(TestBed.inject(ModalService), 'show').and.callFake(() => {
+ return {
+ componentInstance: {
+ submitAction: of({ name })
+ }
+ };
+ });
+ const ecp2 = new ErasureCodeProfile();
+ ecp2.name = name;
+ infoReturn.erasure_code_profiles.push(ecp2);
+ component.addErasureCodeProfile();
+ expect(form.getValue('erasureProfile').name).toBe(name);
+ });
+
+ describe('ecp deletion', () => {
+ let taskWrapper: TaskWrapperService;
+ let deletion: CriticalConfirmationModalComponent;
+ let deleteSpy: jasmine.Spy;
+ let modalSpy: jasmine.Spy;
+ let modal: NgbModalRef;
+
+ const callEcpDeletion = () => {
+ component.deleteErasureCodeProfile();
+ modal.componentInstance.callSubmitAction();
+ };
+
+ const expectSuccessfulEcpDeletion = (name: string) => {
+ setSelectedEcp(name);
+ callEcpDeletion();
+ expect(ecpService.delete).toHaveBeenCalledWith(name);
+ expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ task: {
+ name: 'ecp/delete',
+ metadata: {
+ name: name
+ }
+ }
+ })
+ );
+ };
+
+ beforeEach(() => {
+ deletion = undefined;
+ modalSpy = spyOn(TestBed.inject(ModalService), 'show').and.callFake(
+ (comp: any, init: any) => {
+ modal = modalServiceShow(comp, init);
+ return modal;
+ }
+ );
+ deleteSpy = spyOn(ecpService, 'delete').and.callFake((name: string) => {
+ const profiles = infoReturn.erasure_code_profiles;
+ const index = _.findIndex(profiles, (profile) => profile.name === name);
+ profiles.splice(index, 1);
+ return of({ status: 202 });
+ });
+ taskWrapper = TestBed.inject(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+
+ const ecp2 = new ErasureCodeProfile();
+ ecp2.name = 'someEcpName';
+ infoReturn.erasure_code_profiles.push(ecp2);
+
+ const ecp3 = new ErasureCodeProfile();
+ ecp3.name = 'aDifferentEcpName';
+ infoReturn.erasure_code_profiles.push(ecp3);
+ });
+
+ it('should delete two different erasure code profiles', () => {
+ expectSuccessfulEcpDeletion('someEcpName');
+ expectSuccessfulEcpDeletion('aDifferentEcpName');
+ });
+
+ describe('with unused profile', () => {
+ beforeEach(() => {
+ expectSuccessfulEcpDeletion('someEcpName');
+ });
+
+ it('should not open the tooltip nor the crush info', () => {
+ expect(component.ecpDeletionBtn.isOpen()).toBe(false);
+ expect(component.data.erasureInfo).toBe(false);
+ });
+
+ it('should reload the rules after deletion', () => {
+ const expected = infoReturn.erasure_code_profiles;
+ const currentProfiles = component.info.erasure_code_profiles;
+ expect(currentProfiles.length).toBe(expected.length);
+ expect(currentProfiles).toEqual(expected);
+ });
+ });
+
+ describe('rule in use', () => {
+ beforeEach(() => {
+ spyOn(global, 'setTimeout').and.callFake((fn: Function) => fn());
+ deleteSpy.calls.reset();
+ setSelectedEcp('ecp1');
+ component.deleteErasureCodeProfile();
+ });
+
+ it('should not open the modal', () => {
+ expect(deletion).toBe(undefined);
+ });
+
+ it('should not have called delete and opened the tooltip', () => {
+ expect(ecpService.delete).not.toHaveBeenCalled();
+ expect(component.ecpDeletionBtn.isOpen()).toBe(true);
+ expect(component.data.erasureInfo).toBe(true);
+ });
+
+ it('should hide the tooltip when clicking on delete again', () => {
+ component.deleteErasureCodeProfile();
+ expect(component.ecpDeletionBtn.isOpen()).toBe(false);
+ });
+
+ it('should hide the tooltip when clicking on add', () => {
+ modalSpy.and.callFake((): any => ({
+ componentInstance: {
+ submitAction: of('someProfile')
+ }
+ }));
+ component.addErasureCodeProfile();
+ expect(component.ecpDeletionBtn.isOpen()).toBe(false);
+ });
+
+ it('should hide the tooltip when changing the crush rule', () => {
+ setSelectedEcp('someEcpName');
+ expect(component.ecpDeletionBtn.isOpen()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('submit - create', () => {
+ const setMultipleValues = (settings: object) => {
+ Object.keys(settings).forEach((name) => {
+ formHelper.setValue(name, settings[name]);
+ });
+ };
+
+ describe('erasure coded pool', () => {
+ const expectEcSubmit = (o: any) =>
+ expectValidSubmit(
+ Object.assign(
+ {
+ pool: 'ecPool',
+ pool_type: 'erasure',
+ pg_autoscale_mode: 'off',
+ erasure_code_profile: 'ecp1',
+ pg_num: 4
+ },
+ o
+ )
+ );
+
+ beforeEach(() => {
+ setMultipleValues({
+ name: 'ecPool',
+ poolType: 'erasure',
+ pgNum: 4
+ });
+ });
+
+ it('minimum requirements without ECP to create ec pool', () => {
+ // Mock that no ec profiles exist
+ infoReturn.erasure_code_profiles = [];
+ setUpPoolComponent();
+ setMultipleValues({
+ name: 'minECPool',
+ poolType: 'erasure',
+ pgNum: 4
+ });
+ expectValidSubmit({
+ pool: 'minECPool',
+ pool_type: 'erasure',
+ pg_autoscale_mode: 'off',
+ pg_num: 4
+ });
+ });
+
+ it('creates ec pool with erasure coded profile', () => {
+ const ecp = { name: 'ecpMinimalMock' };
+ setMultipleValues({
+ erasureProfile: ecp
+ });
+ expectEcSubmit({
+ erasure_code_profile: ecp.name
+ });
+ });
+
+ it('creates ec pool with ec_overwrite flag', () => {
+ setMultipleValues({
+ ecOverwrites: true
+ });
+ expectEcSubmit({
+ flags: ['ec_overwrites']
+ });
+ });
+
+ it('should ignore replicated set settings for ec pools', () => {
+ setMultipleValues({
+ size: 2 // will be ignored
+ });
+ expectEcSubmit({});
+ });
+
+ it('creates a pool with compression', () => {
+ setMultipleValues({
+ mode: 'passive',
+ algorithm: 'lz4',
+ minBlobSize: '4 K',
+ maxBlobSize: '4 M',
+ ratio: 0.7
+ });
+ expectEcSubmit({
+ compression_mode: 'passive',
+ compression_algorithm: 'lz4',
+ compression_min_blob_size: 4096,
+ compression_max_blob_size: 4194304,
+ compression_required_ratio: 0.7
+ });
+ });
+
+ it('creates a pool with application metadata', () => {
+ component.data.applications.selected = ['cephfs', 'rgw'];
+ expectEcSubmit({
+ application_metadata: ['cephfs', 'rgw']
+ });
+ });
+ });
+
+ describe('with replicated pool', () => {
+ const expectReplicatedSubmit = (o: any) =>
+ expectValidSubmit(
+ Object.assign(
+ {
+ pool: 'repPool',
+ pool_type: 'replicated',
+ pg_autoscale_mode: 'off',
+ pg_num: 16,
+ rule_name: 'rep1',
+ size: 3
+ },
+ o
+ )
+ );
+ beforeEach(() => {
+ setMultipleValues({
+ name: 'repPool',
+ poolType: 'replicated',
+ crushRule: infoReturn.crush_rules_replicated[0],
+ size: 3,
+ pgNum: 16
+ });
+ });
+
+ it('uses the minimum requirements for replicated pools', () => {
+ // Mock that no replicated rules exist
+ infoReturn.crush_rules_replicated = [];
+ setUpPoolComponent();
+
+ setMultipleValues({
+ name: 'minRepPool',
+ poolType: 'replicated',
+ size: 2,
+ pgNum: 32
+ });
+ expectValidSubmit({
+ pool: 'minRepPool',
+ pool_type: 'replicated',
+ pg_num: 32,
+ pg_autoscale_mode: 'off',
+ size: 2
+ });
+ });
+
+ it('ignores erasure only set settings for replicated pools', () => {
+ setMultipleValues({
+ erasureProfile: { name: 'ecpMinimalMock' }, // Will be ignored
+ ecOverwrites: true // Will be ignored
+ });
+ /**
+ * As pgCalc is triggered through profile changes, which is normally not possible,
+ * if type `replicated` is set, pgNum will be set to 256 with the current rule for
+ * a replicated pool.
+ */
+ expectReplicatedSubmit({
+ pg_num: 256
+ });
+ });
+
+ it('creates a pool with quotas', () => {
+ setMultipleValues({
+ max_bytes: 1024 * 1024,
+ max_objects: 3000
+ });
+ expectReplicatedSubmit({
+ quota_max_bytes: 1024 * 1024,
+ quota_max_objects: 3000
+ });
+ });
+
+ it('creates a pool with rbd qos settings', () => {
+ component.currentConfigurationValues = {
+ rbd_qos_bps_limit: 55
+ };
+ expectReplicatedSubmit({
+ configuration: {
+ rbd_qos_bps_limit: 55
+ }
+ });
+ });
+ });
+ });
+
+ describe('edit mode', () => {
+ const setUrl = (url: string) => {
+ Object.defineProperty(router, 'url', { value: url });
+ setUpPoolComponent(); // Renew of component needed because the constructor has to be called
+ };
+
+ let pool: Pool;
+ beforeEach(() => {
+ pool = new Pool('somePoolName');
+ pool.type = 'replicated';
+ pool.size = 3;
+ pool.crush_rule = 'rep1';
+ pool.pg_num = 32;
+ pool.options = {};
+ pool.options.compression_mode = 'passive';
+ pool.options.compression_algorithm = 'lz4';
+ pool.options.compression_min_blob_size = 1024 * 512;
+ pool.options.compression_max_blob_size = 1024 * 1024;
+ pool.options.compression_required_ratio = 0.8;
+ pool.flags_names = 'someFlag1,someFlag2';
+ pool.application_metadata = ['rbd', 'ownApp'];
+ pool.quota_max_bytes = 1024 * 1024 * 1024;
+ pool.quota_max_objects = 3000;
+
+ Mocks.getCrushRule({ name: 'someRule' });
+ spyOn(poolService, 'get').and.callFake(() => of(pool));
+ });
+
+ it('is not in edit mode if edit is not included in url', () => {
+ setUrl('/pool/add');
+ expect(component.editing).toBeFalsy();
+ });
+
+ it('is in edit mode if edit is included in url', () => {
+ setUrl('/pool/edit/somePoolName');
+ expect(component.editing).toBeTruthy();
+ });
+
+ describe('after ngOnInit', () => {
+ beforeEach(() => {
+ setUrl('/pool/edit/somePoolName');
+ fixture.detectChanges();
+ });
+
+ it('disabled inputs', () => {
+ fixture.detectChanges();
+ const disabled = ['poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'];
+ disabled.forEach((controlName) => {
+ return expect(form.get(controlName).disabled).toBeTruthy();
+ });
+ const enabled = [
+ 'name',
+ 'pgNum',
+ 'mode',
+ 'algorithm',
+ 'minBlobSize',
+ 'maxBlobSize',
+ 'ratio',
+ 'max_bytes',
+ 'max_objects'
+ ];
+ enabled.forEach((controlName) => {
+ return expect(form.get(controlName).enabled).toBeTruthy();
+ });
+ });
+
+ it('should include the custom app as valid option', () => {
+ expect(
+ component.data.applications.available.map((app: Record<string, any>) => app.name)
+ ).toEqual(['cephfs', 'ownApp', 'rbd', 'rgw']);
+ });
+
+ it('set all control values to the given pool', () => {
+ expect(form.getValue('name')).toBe(pool.pool_name);
+ expect(form.getValue('poolType')).toBe(pool.type);
+ expect(form.getValue('crushRule')).toEqual(component.info.crush_rules_replicated[0]);
+ expect(form.getValue('size')).toBe(pool.size);
+ expect(form.getValue('pgNum')).toBe(pool.pg_num);
+ expect(form.getValue('mode')).toBe(pool.options.compression_mode);
+ expect(form.getValue('algorithm')).toBe(pool.options.compression_algorithm);
+ expect(form.getValue('minBlobSize')).toBe('512 KiB');
+ expect(form.getValue('maxBlobSize')).toBe('1 MiB');
+ expect(form.getValue('ratio')).toBe(pool.options.compression_required_ratio);
+ expect(form.getValue('max_bytes')).toBe('1 GiB');
+ expect(form.getValue('max_objects')).toBe(pool.quota_max_objects);
+ });
+
+ it('updates pgs on every change', () => {
+ testPgUpdate(undefined, -1, 16);
+ testPgUpdate(undefined, -1, 8);
+ });
+
+ it('is possible to use less or more pgs than before', () => {
+ formHelper.expectValid(setPgNum(64));
+ formHelper.expectValid(setPgNum(4));
+ });
+
+ describe('submit', () => {
+ const markControlAsPreviouslySet = (controlName: string) =>
+ form.get(controlName).markAsPristine();
+
+ beforeEach(() => {
+ [
+ 'algorithm',
+ 'maxBlobSize',
+ 'minBlobSize',
+ 'mode',
+ 'pgNum',
+ 'ratio',
+ 'name'
+ ].forEach((name) => markControlAsPreviouslySet(name));
+ fixture.detectChanges();
+ });
+
+ it(`always provides the application metadata array with submit even if it's empty`, () => {
+ expect(form.get('mode').dirty).toBe(false);
+ component.data.applications.selected = [];
+ expectValidSubmit(
+ {
+ application_metadata: [],
+ pool: 'somePoolName'
+ },
+ 'pool/edit',
+ 'update'
+ );
+ });
+
+ it(`will always provide reset value for compression options`, () => {
+ formHelper.setValue('minBlobSize', '').markAsDirty();
+ formHelper.setValue('maxBlobSize', '').markAsDirty();
+ formHelper.setValue('ratio', '').markAsDirty();
+ expectValidSubmit(
+ {
+ application_metadata: ['ownApp', 'rbd'],
+ compression_max_blob_size: 0,
+ compression_min_blob_size: 0,
+ compression_required_ratio: 0,
+ pool: 'somePoolName'
+ },
+ 'pool/edit',
+ 'update'
+ );
+ });
+
+ it(`will unset mode not used anymore`, () => {
+ formHelper.setValue('mode', 'none').markAsDirty();
+ expectValidSubmit(
+ {
+ application_metadata: ['ownApp', 'rbd'],
+ compression_mode: 'unset',
+ pool: 'somePoolName'
+ },
+ 'pool/edit',
+ 'update'
+ );
+ });
+ });
+ });
+ });
+
+ describe('test pool configuration component', () => {
+ it('is visible for replicated pools with rbd application', () => {
+ const poolType = component.form.get('poolType');
+ poolType.markAsDirty();
+ poolType.setValue('replicated');
+ component.data.applications.selected = ['rbd'];
+ fixture.detectChanges();
+ expect(
+ fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
+ .hidden
+ ).toBe(false);
+ });
+
+ it('is invisible for erasure coded pools', () => {
+ const poolType = component.form.get('poolType');
+ poolType.markAsDirty();
+ poolType.setValue('erasure');
+ fixture.detectChanges();
+ expect(
+ fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
+ .hidden
+ ).toBe(true);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
new file mode 100644
index 000000000..4778562cb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
@@ -0,0 +1,919 @@
+import { Component, OnInit, Type, ViewChild } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { NgbNav, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Observable, ReplaySubject, Subscription } from 'rxjs';
+
+import { DashboardNotFoundError } from '~/app/core/error/error';
+import { CrushRuleService } from '~/app/shared/api/crush-rule.service';
+import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { CrushNodeSelectionClass } from '~/app/shared/classes/crush.node.selection.class';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import {
+ RbdConfigurationEntry,
+ RbdConfigurationSourceField
+} from '~/app/shared/models/configuration';
+import { CrushRule } from '~/app/shared/models/crush-rule';
+import { CrushStep } from '~/app/shared/models/crush-step';
+import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permission } from '~/app/shared/models/permissions';
+import { PoolFormInfo } from '~/app/shared/models/pool-form-info';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CrushRuleFormModalComponent } from '../crush-rule-form-modal/crush-rule-form-modal.component';
+import { ErasureCodeProfileFormModalComponent } from '../erasure-code-profile-form/erasure-code-profile-form-modal.component';
+import { Pool } from '../pool';
+import { PoolFormData } from './pool-form-data';
+
+interface FormFieldDescription {
+ externalFieldName: string;
+ formControlName: string;
+ attr?: string;
+ replaceFn?: Function;
+ editable?: boolean;
+ resetValue?: any;
+}
+
+@Component({
+ selector: 'cd-pool-form',
+ templateUrl: './pool-form.component.html',
+ styleUrls: ['./pool-form.component.scss']
+})
+export class PoolFormComponent extends CdForm implements OnInit {
+ @ViewChild('crushInfoTabs') crushInfoTabs: NgbNav;
+ @ViewChild('crushDeletionBtn') crushDeletionBtn: NgbTooltip;
+ @ViewChild('ecpInfoTabs') ecpInfoTabs: NgbNav;
+ @ViewChild('ecpDeletionBtn') ecpDeletionBtn: NgbTooltip;
+
+ permission: Permission;
+ form: CdFormGroup;
+ ecProfiles: ErasureCodeProfile[];
+ info: PoolFormInfo;
+ routeParamsSubscribe: any;
+ editing = false;
+ isReplicated = false;
+ isErasure = false;
+ data = new PoolFormData();
+ externalPgChange = false;
+ current: Record<string, any> = {
+ rules: []
+ };
+ initializeConfigData = new ReplaySubject<{
+ initialData: RbdConfigurationEntry[];
+ sourceType: RbdConfigurationSourceField;
+ }>(1);
+ currentConfigurationValues: { [configKey: string]: any } = {};
+ action: string;
+ resource: string;
+ icons = Icons;
+ pgAutoscaleModes: string[];
+ crushUsage: string[] = undefined; // Will only be set if a rule is used by some pool
+ ecpUsage: string[] = undefined; // Will only be set if a rule is used by some pool
+
+ private modalSubscription: Subscription;
+
+ constructor(
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private route: ActivatedRoute,
+ private router: Router,
+ private modalService: ModalService,
+ private poolService: PoolService,
+ private authStorageService: AuthStorageService,
+ private formatter: FormatterService,
+ private taskWrapper: TaskWrapperService,
+ private ecpService: ErasureCodeProfileService,
+ private crushRuleService: CrushRuleService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.editing = this.router.url.startsWith(`/pool/${URLVerbs.EDIT}`);
+ this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
+ this.resource = $localize`pool`;
+ this.authenticate();
+ this.createForm();
+ }
+
+ authenticate() {
+ this.permission = this.authStorageService.getPermissions().pool;
+ if (
+ !this.permission.read ||
+ (!this.permission.update && this.editing) ||
+ (!this.permission.create && !this.editing)
+ ) {
+ throw new DashboardNotFoundError();
+ }
+ }
+
+ private createForm() {
+ const compressionForm = new CdFormGroup({
+ mode: new FormControl('none'),
+ algorithm: new FormControl(''),
+ minBlobSize: new FormControl('', {
+ updateOn: 'blur'
+ }),
+ maxBlobSize: new FormControl('', {
+ updateOn: 'blur'
+ }),
+ ratio: new FormControl('', {
+ updateOn: 'blur'
+ })
+ });
+
+ this.form = new CdFormGroup(
+ {
+ name: new FormControl('', {
+ validators: [
+ Validators.pattern(/^[.A-Za-z0-9_/-]+$/),
+ Validators.required,
+ CdValidators.custom('rbdPool', () => {
+ return (
+ this.form &&
+ this.form.getValue('name').includes('/') &&
+ this.data &&
+ this.data.applications.selected.indexOf('rbd') !== -1
+ );
+ })
+ ]
+ }),
+ poolType: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ crushRule: new FormControl(null, {
+ validators: [
+ CdValidators.custom(
+ 'tooFewOsds',
+ (rule: any) => this.info && rule && this.info.osd_count < rule.min_size
+ ),
+ CdValidators.custom(
+ 'required',
+ (rule: CrushRule) =>
+ this.isReplicated && this.info.crush_rules_replicated.length > 0 && !rule
+ )
+ ]
+ }),
+ size: new FormControl('', {
+ updateOn: 'blur'
+ }),
+ erasureProfile: new FormControl(null),
+ pgNum: new FormControl('', {
+ validators: [Validators.required]
+ }),
+ pgAutoscaleMode: new FormControl(null),
+ ecOverwrites: new FormControl(false),
+ compression: compressionForm,
+ max_bytes: new FormControl(''),
+ max_objects: new FormControl(0)
+ },
+ [CdValidators.custom('form', (): null => null)]
+ );
+ }
+
+ ngOnInit() {
+ this.poolService.getInfo().subscribe((info: PoolFormInfo) => {
+ this.initInfo(info);
+ if (this.editing) {
+ this.initEditMode();
+ } else {
+ this.setAvailableApps();
+ this.loadingReady();
+ }
+ this.listenToChanges();
+ this.setComplexValidators();
+ });
+ }
+
+ private initInfo(info: PoolFormInfo) {
+ this.pgAutoscaleModes = info.pg_autoscale_modes;
+ this.form.silentSet('pgAutoscaleMode', info.pg_autoscale_default_mode);
+ this.form.silentSet('algorithm', info.bluestore_compression_algorithm);
+ this.info = info;
+ this.initEcp(info.erasure_code_profiles);
+ }
+
+ private initEcp(ecProfiles: ErasureCodeProfile[]) {
+ this.setListControlStatus('erasureProfile', ecProfiles);
+ this.ecProfiles = ecProfiles;
+ }
+
+ /**
+ * Used to update the crush rule or erasure code profile listings.
+ *
+ * If only one rule or profile exists it will be selected.
+ * If nothing exists null will be selected.
+ * If more than one rule or profile exists the listing will be enabled,
+ * otherwise disabled.
+ */
+ private setListControlStatus(controlName: string, arr: any[]) {
+ const control = this.form.get(controlName);
+ const value = control.value;
+ if (arr.length === 1 && (!value || !_.isEqual(value, arr[0]))) {
+ control.setValue(arr[0]);
+ } else if (arr.length === 0 && value) {
+ control.setValue(null);
+ }
+ if (arr.length <= 1) {
+ if (control.enabled) {
+ control.disable();
+ }
+ } else if (control.disabled) {
+ control.enable();
+ }
+ }
+
+ private initEditMode() {
+ this.disableForEdit();
+ this.routeParamsSubscribe = this.route.params.subscribe((param: { name: string }) =>
+ this.poolService.get(param.name).subscribe((pool: Pool) => {
+ this.data.pool = pool;
+ this.initEditFormData(pool);
+ this.loadingReady();
+ })
+ );
+ }
+
+ private disableForEdit() {
+ ['poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach((controlName) =>
+ this.form.get(controlName).disable()
+ );
+ }
+
+ private initEditFormData(pool: Pool) {
+ this.initializeConfigData.next({
+ initialData: pool.configuration,
+ sourceType: RbdConfigurationSourceField.pool
+ });
+ this.poolTypeChange(pool.type);
+ const rules = this.info.crush_rules_replicated.concat(this.info.crush_rules_erasure);
+ const dataMap = {
+ name: pool.pool_name,
+ poolType: pool.type,
+ crushRule: rules.find((rule: CrushRule) => rule.rule_name === pool.crush_rule),
+ size: pool.size,
+ erasureProfile: this.ecProfiles.find((ecp) => ecp.name === pool.erasure_code_profile),
+ pgAutoscaleMode: pool.pg_autoscale_mode,
+ pgNum: pool.pg_num,
+ ecOverwrites: pool.flags_names.includes('ec_overwrites'),
+ mode: pool.options.compression_mode,
+ algorithm: pool.options.compression_algorithm,
+ minBlobSize: this.dimlessBinaryPipe.transform(pool.options.compression_min_blob_size),
+ maxBlobSize: this.dimlessBinaryPipe.transform(pool.options.compression_max_blob_size),
+ ratio: pool.options.compression_required_ratio,
+ max_bytes: this.dimlessBinaryPipe.transform(pool.quota_max_bytes),
+ max_objects: pool.quota_max_objects
+ };
+ Object.keys(dataMap).forEach((controlName: string) => {
+ const value = dataMap[controlName];
+ if (!_.isUndefined(value) && value !== '') {
+ this.form.silentSet(controlName, value);
+ }
+ });
+ this.data.pgs = this.form.getValue('pgNum');
+ this.setAvailableApps(this.data.applications.default.concat(pool.application_metadata));
+ this.data.applications.selected = pool.application_metadata;
+ }
+
+ private setAvailableApps(apps: string[] = this.data.applications.default) {
+ this.data.applications.available = _.uniq(apps.sort()).map(
+ (x: string) => new SelectOption(false, x, '')
+ );
+ }
+
+ private listenToChanges() {
+ this.listenToChangesDuringAddEdit();
+ if (!this.editing) {
+ this.listenToChangesDuringAdd();
+ }
+ }
+
+ private listenToChangesDuringAddEdit() {
+ this.form.get('pgNum').valueChanges.subscribe((pgs) => {
+ const change = pgs - this.data.pgs;
+ if (Math.abs(change) !== 1 || pgs === 2) {
+ this.data.pgs = pgs;
+ return;
+ }
+ this.doPgPowerJump(change as 1 | -1);
+ });
+ }
+
+ private doPgPowerJump(jump: 1 | -1) {
+ const power = this.calculatePgPower() + jump;
+ this.setPgs(jump === -1 ? Math.round(power) : Math.floor(power));
+ }
+
+ private calculatePgPower(pgs = this.form.getValue('pgNum')): number {
+ return Math.log(pgs) / Math.log(2);
+ }
+
+ private setPgs(power: number) {
+ const pgs = Math.pow(2, power < 0 ? 0 : power); // Set size the nearest accurate size.
+ this.data.pgs = pgs;
+ this.form.silentSet('pgNum', pgs);
+ }
+
+ private listenToChangesDuringAdd() {
+ this.form.get('poolType').valueChanges.subscribe((poolType) => {
+ this.poolTypeChange(poolType);
+ });
+ this.form.get('crushRule').valueChanges.subscribe((rule) => {
+ // The crush rule can only be changed if type 'replicated' is set.
+ if (this.crushDeletionBtn && this.crushDeletionBtn.isOpen()) {
+ this.crushDeletionBtn.close();
+ }
+ if (!rule) {
+ return;
+ }
+ this.setCorrectMaxSize(rule);
+ this.crushRuleIsUsedBy(rule.rule_name);
+ this.replicatedRuleChange();
+ this.pgCalc();
+ });
+ this.form.get('size').valueChanges.subscribe(() => {
+ // The size can only be changed if type 'replicated' is set.
+ this.pgCalc();
+ });
+ this.form.get('erasureProfile').valueChanges.subscribe((profile) => {
+ // The ec profile can only be changed if type 'erasure' is set.
+ if (this.ecpDeletionBtn && this.ecpDeletionBtn.isOpen()) {
+ this.ecpDeletionBtn.close();
+ }
+ if (!profile) {
+ return;
+ }
+ this.ecpIsUsedBy(profile.name);
+ this.pgCalc();
+ });
+ this.form.get('mode').valueChanges.subscribe(() => {
+ ['minBlobSize', 'maxBlobSize', 'ratio'].forEach((name) => {
+ this.form.get(name).updateValueAndValidity({ emitEvent: false });
+ });
+ });
+ this.form.get('minBlobSize').valueChanges.subscribe(() => {
+ this.form.get('maxBlobSize').updateValueAndValidity({ emitEvent: false });
+ });
+ this.form.get('maxBlobSize').valueChanges.subscribe(() => {
+ this.form.get('minBlobSize').updateValueAndValidity({ emitEvent: false });
+ });
+ }
+
+ private poolTypeChange(poolType: string) {
+ if (poolType === 'replicated') {
+ this.setTypeBooleans(true, false);
+ } else if (poolType === 'erasure') {
+ this.setTypeBooleans(false, true);
+ } else {
+ this.setTypeBooleans(false, false);
+ }
+ if (!poolType || !this.info) {
+ this.current.rules = [];
+ return;
+ }
+ const rules = this.info['crush_rules_' + poolType] || [];
+ this.current.rules = rules;
+ if (this.editing) {
+ return;
+ }
+ if (this.isReplicated) {
+ this.setListControlStatus('crushRule', rules);
+ }
+ this.replicatedRuleChange();
+ this.pgCalc();
+ }
+
+ private setTypeBooleans(replicated: boolean, erasure: boolean) {
+ this.isReplicated = replicated;
+ this.isErasure = erasure;
+ }
+
+ private replicatedRuleChange() {
+ if (!this.isReplicated) {
+ return;
+ }
+ const control = this.form.get('size');
+ let size = this.form.getValue('size') || 3;
+ const min = this.getMinSize();
+ const max = this.getMaxSize();
+ if (size < min) {
+ size = min;
+ } else if (size > max) {
+ size = max;
+ }
+ if (size !== control.value) {
+ this.form.silentSet('size', size);
+ }
+ }
+
+ getMinSize(): number {
+ if (!this.info || this.info.osd_count < 1) {
+ return 0;
+ }
+ const rule = this.form.getValue('crushRule');
+ if (rule) {
+ return rule.min_size;
+ }
+ return 1;
+ }
+
+ getMaxSize(): number {
+ const rule = this.form.getValue('crushRule');
+ if (!this.info) {
+ return 0;
+ }
+ if (!rule) {
+ const osds = this.info.osd_count;
+ const defaultSize = 3;
+ return Math.min(osds, defaultSize);
+ }
+ return rule.usable_size;
+ }
+
+ private pgCalc() {
+ const poolType = this.form.getValue('poolType');
+ if (!this.info || this.form.get('pgNum').dirty || !poolType) {
+ return;
+ }
+ const pgMax = this.info.osd_count * 100;
+ const pgs = this.isReplicated ? this.replicatedPgCalc(pgMax) : this.erasurePgCalc(pgMax);
+ if (!pgs) {
+ return;
+ }
+ const oldValue = this.data.pgs;
+ this.alignPgs(pgs);
+ const newValue = this.data.pgs;
+ if (!this.externalPgChange) {
+ this.externalPgChange = oldValue !== newValue;
+ }
+ }
+
+ private setCorrectMaxSize(rule: CrushRule = this.form.getValue('crushRule')) {
+ if (!rule) {
+ return;
+ }
+ const domains = CrushNodeSelectionClass.searchFailureDomains(
+ this.info.nodes,
+ rule.steps[0].item_name
+ );
+ const currentDomain = domains[rule.steps[1].type];
+ const usable = currentDomain ? currentDomain.length : rule.max_size;
+ rule.usable_size = Math.min(usable, rule.max_size);
+ }
+
+ private replicatedPgCalc(pgs: number): number {
+ const sizeControl = this.form.get('size');
+ const size = sizeControl.value;
+ return sizeControl.valid && size > 0 ? pgs / size : 0;
+ }
+
+ private erasurePgCalc(pgs: number): number {
+ const ecpControl = this.form.get('erasureProfile');
+ const ecp = ecpControl.value;
+ return (ecpControl.valid || ecpControl.disabled) && ecp ? pgs / (ecp.k + ecp.m) : 0;
+ }
+
+ alignPgs(pgs = this.form.getValue('pgNum')) {
+ this.setPgs(Math.round(this.calculatePgPower(pgs < 1 ? 1 : pgs)));
+ }
+
+ private setComplexValidators() {
+ if (this.editing) {
+ this.form
+ .get('name')
+ .setValidators([
+ this.form.get('name').validator,
+ CdValidators.custom(
+ 'uniqueName',
+ (name: string) =>
+ this.data.pool &&
+ this.info &&
+ this.info.pool_names.indexOf(name) !== -1 &&
+ this.info.pool_names.indexOf(name) !==
+ this.info.pool_names.indexOf(this.data.pool.pool_name)
+ )
+ ]);
+ } else {
+ CdValidators.validateIf(this.form.get('size'), () => this.isReplicated, [
+ CdValidators.custom(
+ 'min',
+ (value: number) => this.form.getValue('size') && value < this.getMinSize()
+ ),
+ CdValidators.custom(
+ 'max',
+ (value: number) => this.form.getValue('size') && this.getMaxSize() < value
+ )
+ ]);
+ this.form
+ .get('name')
+ .setValidators([
+ this.form.get('name').validator,
+ CdValidators.custom(
+ 'uniqueName',
+ (name: string) => this.info && this.info.pool_names.indexOf(name) !== -1
+ )
+ ]);
+ }
+ this.setCompressionValidators();
+ }
+
+ private setCompressionValidators() {
+ CdValidators.validateIf(this.form.get('minBlobSize'), () => this.hasCompressionEnabled(), [
+ Validators.min(0),
+ CdValidators.custom('maximum', (size: string) =>
+ this.oddBlobSize(size, this.form.getValue('maxBlobSize'))
+ )
+ ]);
+ CdValidators.validateIf(this.form.get('maxBlobSize'), () => this.hasCompressionEnabled(), [
+ Validators.min(0),
+ CdValidators.custom('minimum', (size: string) =>
+ this.oddBlobSize(this.form.getValue('minBlobSize'), size)
+ )
+ ]);
+ CdValidators.validateIf(this.form.get('ratio'), () => this.hasCompressionEnabled(), [
+ Validators.min(0),
+ Validators.max(1)
+ ]);
+ }
+
+ private oddBlobSize(minimum: string, maximum: string) {
+ const min = this.formatter.toBytes(minimum);
+ const max = this.formatter.toBytes(maximum);
+ return Boolean(min && max && min >= max);
+ }
+
+ hasCompressionEnabled() {
+ return this.form.getValue('mode') && this.form.get('mode').value.toLowerCase() !== 'none';
+ }
+
+ describeCrushStep(step: CrushStep) {
+ return [
+ step.op.replace('_', ' '),
+ step.item_name || '',
+ step.type ? step.num + ' type ' + step.type : ''
+ ].join(' ');
+ }
+
+ addErasureCodeProfile() {
+ this.addModal(ErasureCodeProfileFormModalComponent, (name) => this.reloadECPs(name));
+ }
+
+ private addModal(modalComponent: Type<any>, reload: (name: string) => void) {
+ this.hideOpenTooltips();
+ const modalRef = this.modalService.show(modalComponent);
+ modalRef.componentInstance.submitAction.subscribe((item: any) => {
+ reload(item.name);
+ });
+ }
+
+ private hideOpenTooltips() {
+ const hideTooltip = (btn: NgbTooltip) => btn && btn.isOpen() && btn.close();
+ hideTooltip(this.ecpDeletionBtn);
+ hideTooltip(this.crushDeletionBtn);
+ }
+
+ private reloadECPs(profileName?: string) {
+ this.reloadList({
+ newItemName: profileName,
+ getInfo: () => this.ecpService.list(),
+ initInfo: (profiles) => this.initEcp(profiles),
+ findNewItem: () => this.ecProfiles.find((p) => p.name === profileName),
+ controlName: 'erasureProfile'
+ });
+ }
+
+ private reloadList({
+ newItemName,
+ getInfo,
+ initInfo,
+ findNewItem,
+ controlName
+ }: {
+ newItemName: string;
+ getInfo: () => Observable<any>;
+ initInfo: (items: any) => void;
+ findNewItem: () => any;
+ controlName: string;
+ }) {
+ if (this.modalSubscription) {
+ this.modalSubscription.unsubscribe();
+ }
+ getInfo().subscribe((items: any) => {
+ initInfo(items);
+ if (!newItemName) {
+ return;
+ }
+ const item = findNewItem();
+ if (item) {
+ this.form.get(controlName).setValue(item);
+ }
+ });
+ }
+
+ deleteErasureCodeProfile() {
+ this.deletionModal({
+ value: this.form.getValue('erasureProfile'),
+ usage: this.ecpUsage,
+ deletionBtn: this.ecpDeletionBtn,
+ dataName: 'erasureInfo',
+ getTabs: () => this.ecpInfoTabs,
+ tabPosition: 'used-by-pools',
+ nameAttribute: 'name',
+ itemDescription: $localize`erasure code profile`,
+ reloadFn: () => this.reloadECPs(),
+ deleteFn: (name) => this.ecpService.delete(name),
+ taskName: 'ecp/delete'
+ });
+ }
+
+ private deletionModal({
+ value,
+ usage,
+ deletionBtn,
+ dataName,
+ getTabs,
+ tabPosition,
+ nameAttribute,
+ itemDescription,
+ reloadFn,
+ deleteFn,
+ taskName
+ }: {
+ value: any;
+ usage: string[];
+ deletionBtn: NgbTooltip;
+ dataName: string;
+ getTabs: () => NgbNav;
+ tabPosition: string;
+ nameAttribute: string;
+ itemDescription: string;
+ reloadFn: Function;
+ deleteFn: (name: string) => Observable<any>;
+ taskName: string;
+ }) {
+ if (!value) {
+ return;
+ }
+ if (usage) {
+ deletionBtn.animation = false;
+ deletionBtn.toggle();
+ this.data[dataName] = true;
+ setTimeout(() => {
+ const tabs = getTabs();
+ if (tabs) {
+ tabs.select(tabPosition);
+ }
+ }, 50);
+ return;
+ }
+ const name = value[nameAttribute];
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription,
+ itemNames: [name],
+ submitActionObservable: () => {
+ const deletion = deleteFn(name);
+ deletion.subscribe(() => reloadFn());
+ return this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask(taskName, { name: name }),
+ call: deletion
+ });
+ }
+ });
+ }
+
+ addCrushRule() {
+ this.addModal(CrushRuleFormModalComponent, (name) => this.reloadCrushRules(name));
+ }
+
+ private reloadCrushRules(ruleName?: string) {
+ this.reloadList({
+ newItemName: ruleName,
+ getInfo: () => this.poolService.getInfo(),
+ initInfo: (info) => {
+ this.initInfo(info);
+ this.poolTypeChange('replicated');
+ },
+ findNewItem: () =>
+ this.info.crush_rules_replicated.find((rule) => rule.rule_name === ruleName),
+ controlName: 'crushRule'
+ });
+ }
+
+ deleteCrushRule() {
+ this.deletionModal({
+ value: this.form.getValue('crushRule'),
+ usage: this.crushUsage,
+ deletionBtn: this.crushDeletionBtn,
+ dataName: 'crushInfo',
+ getTabs: () => this.crushInfoTabs,
+ tabPosition: 'used-by-pools',
+ nameAttribute: 'rule_name',
+ itemDescription: $localize`crush rule`,
+ reloadFn: () => this.reloadCrushRules(),
+ deleteFn: (name) => this.crushRuleService.delete(name),
+ taskName: 'crushRule/delete'
+ });
+ }
+
+ crushRuleIsUsedBy(ruleName: string) {
+ this.crushUsage = ruleName ? this.info.used_rules[ruleName] : undefined;
+ }
+
+ ecpIsUsedBy(profileName: string) {
+ this.ecpUsage = profileName ? this.info.used_profiles[profileName] : undefined;
+ }
+
+ submit() {
+ if (this.form.invalid) {
+ this.form.setErrors({ cdSubmitButton: true });
+ return;
+ }
+
+ const pool = { pool: this.form.getValue('name') };
+
+ this.assignFormFields(pool, [
+ { externalFieldName: 'pool_type', formControlName: 'poolType' },
+ {
+ externalFieldName: 'pg_autoscale_mode',
+ formControlName: 'pgAutoscaleMode',
+ editable: true
+ },
+ {
+ externalFieldName: 'pg_num',
+ formControlName: 'pgNum',
+ replaceFn: (value: number) => (this.form.getValue('pgAutoscaleMode') === 'on' ? 1 : value),
+ editable: true
+ },
+ this.isReplicated
+ ? { externalFieldName: 'size', formControlName: 'size' }
+ : {
+ externalFieldName: 'erasure_code_profile',
+ formControlName: 'erasureProfile',
+ attr: 'name'
+ },
+ {
+ externalFieldName: 'rule_name',
+ formControlName: 'crushRule',
+ replaceFn: (value: CrushRule) => (this.isReplicated ? value && value.rule_name : undefined)
+ },
+ {
+ externalFieldName: 'quota_max_bytes',
+ formControlName: 'max_bytes',
+ replaceFn: this.formatter.toBytes,
+ editable: true,
+ resetValue: this.editing ? 0 : undefined
+ },
+ {
+ externalFieldName: 'quota_max_objects',
+ formControlName: 'max_objects',
+ editable: true,
+ resetValue: this.editing ? 0 : undefined
+ }
+ ]);
+
+ if (this.info.is_all_bluestore) {
+ this.assignFormField(pool, {
+ externalFieldName: 'flags',
+ formControlName: 'ecOverwrites',
+ replaceFn: () => (this.isErasure ? ['ec_overwrites'] : undefined)
+ });
+
+ if (this.form.getValue('mode') !== 'none') {
+ this.assignFormFields(pool, [
+ {
+ externalFieldName: 'compression_mode',
+ formControlName: 'mode',
+ editable: true,
+ replaceFn: (value: boolean) => this.hasCompressionEnabled() && value
+ },
+ {
+ externalFieldName: 'compression_algorithm',
+ formControlName: 'algorithm',
+ editable: true
+ },
+ {
+ externalFieldName: 'compression_min_blob_size',
+ formControlName: 'minBlobSize',
+ replaceFn: this.formatter.toBytes,
+ editable: true,
+ resetValue: 0
+ },
+ {
+ externalFieldName: 'compression_max_blob_size',
+ formControlName: 'maxBlobSize',
+ replaceFn: this.formatter.toBytes,
+ editable: true,
+ resetValue: 0
+ },
+ {
+ externalFieldName: 'compression_required_ratio',
+ formControlName: 'ratio',
+ editable: true,
+ resetValue: 0
+ }
+ ]);
+ } else if (this.editing) {
+ this.assignFormFields(pool, [
+ {
+ externalFieldName: 'compression_mode',
+ formControlName: 'mode',
+ editable: true,
+ replaceFn: () => 'unset' // Is used if no compression is set
+ },
+ {
+ externalFieldName: 'srcpool',
+ formControlName: 'name',
+ editable: true,
+ replaceFn: () => this.data.pool.pool_name
+ }
+ ]);
+ }
+ }
+
+ const apps = this.data.applications.selected;
+ if (apps.length > 0 || this.editing) {
+ pool['application_metadata'] = apps;
+ }
+
+ // Only collect configuration data for replicated pools, as QoS cannot be configured on EC
+ // pools. EC data pools inherit their settings from the corresponding replicated metadata pool.
+ if (this.isReplicated && !_.isEmpty(this.currentConfigurationValues)) {
+ pool['configuration'] = this.currentConfigurationValues;
+ }
+
+ this.triggerApiTask(pool);
+ }
+
+ /**
+ * Retrieves the values for the given form field descriptions and assigns the values to the given
+ * object. This method differentiates between `add` and `edit` mode and acts differently on one or
+ * the other.
+ */
+ private assignFormFields(pool: object, formFieldDescription: FormFieldDescription[]): void {
+ formFieldDescription.forEach((item) => this.assignFormField(pool, item));
+ }
+
+ /**
+ * Retrieves the value for the given form field description and assigns the values to the given
+ * object. This method differentiates between `add` and `edit` mode and acts differently on one or
+ * the other.
+ */
+ private assignFormField(
+ pool: object,
+ {
+ externalFieldName,
+ formControlName,
+ attr,
+ replaceFn,
+ editable,
+ resetValue
+ }: FormFieldDescription
+ ): void {
+ if (this.editing && (!editable || this.form.get(formControlName).pristine)) {
+ return;
+ }
+ const value = this.form.getValue(formControlName);
+ let apiValue = replaceFn ? replaceFn(value) : attr ? _.get(value, attr) : value;
+ if (!value || !apiValue) {
+ if (editable && !_.isUndefined(resetValue)) {
+ apiValue = resetValue;
+ } else {
+ return;
+ }
+ }
+ pool[externalFieldName] = apiValue;
+ }
+
+ private triggerApiTask(pool: Record<string, any>) {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('pool/' + (this.editing ? URLVerbs.EDIT : URLVerbs.CREATE), {
+ pool_name: pool.hasOwnProperty('srcpool') ? pool.srcpool : pool.pool
+ }),
+ call: this.poolService[this.editing ? URLVerbs.UPDATE : URLVerbs.CREATE](pool)
+ })
+ .subscribe({
+ error: (resp) => {
+ if (_.isObject(resp.error) && resp.error.code === '34') {
+ this.form.get('pgNum').setErrors({ '34': true });
+ }
+ this.form.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => this.router.navigate(['/pool'])
+ });
+ }
+
+ appSelection() {
+ this.form.get('name').updateValueAndValidity({ emitEvent: false, onlySelf: true });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html
new file mode 100644
index 000000000..2d3ee6976
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html
@@ -0,0 +1,57 @@
+<ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem>
+ <a ngbNavLink
+ i18n>Pools List</a>
+ <ng-template ngbNavContent>
+ <cd-table #table
+ id="pool-list"
+ [data]="pools"
+ [columns]="columns"
+ selectionType="single"
+ [hasDetails]="true"
+ [status]="tableStatus"
+ [autoReload]="-1"
+ (fetchData)="taskListService.fetch()"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions id="pool-list-actions"
+ class="table-actions"
+ [permission]="permissions.pool"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-pool-details cdTableDetail
+ id="pool-list-details"
+ [selection]="expandedRow"
+ [permissions]="permissions"
+ [cacheTiers]="cacheTiers">
+ </cd-pool-details>
+ </cd-table>
+ </ng-template>
+ </li>
+
+ <li ngbNavItem
+ *ngIf="permissions.grafana.read">
+ <a ngbNavLink
+ i18n>Overall Performance</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'ceph-pools-overview?'"
+ uid="z99hzWtmk"
+ grafanaStyle="two">
+ </cd-grafana>
+ </ng-template>
+ </li>
+</ul>
+
+<div [ngbNavOutlet]="nav"></div>
+
+<ng-template #poolUsageTpl
+ let-row="row">
+ <cd-usage-bar *ngIf="row.stats?.avail_raw?.latest"
+ [total]="row.stats.bytes_used.latest + row.stats.avail_raw.latest"
+ [used]="row.stats.bytes_used.latest"
+ decimals="2">
+ </cd-usage-bar>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.scss
new file mode 100644
index 000000000..709e8aeb2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.scss
@@ -0,0 +1,19 @@
+@use './src/styles/vendor/variables' as vv;
+
+::ng-deep cd-pool-list {
+ .pg-clean {
+ color: vv.$success;
+ }
+
+ .pg-working {
+ color: vv.$primary;
+ }
+
+ .pg-warning {
+ color: vv.$warning;
+ }
+
+ .pg-unknown {
+ color: vv.$danger;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts
new file mode 100644
index 000000000..8a8af7b73
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts
@@ -0,0 +1,518 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RbdConfigurationListComponent } from '~/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component';
+import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, expectItemTasks, Mocks } from '~/testing/unit-test-helper';
+import { Pool } from '../pool';
+import { PoolDetailsComponent } from '../pool-details/pool-details.component';
+import { PoolListComponent } from './pool-list.component';
+
+describe('PoolListComponent', () => {
+ let component: PoolListComponent;
+ let fixture: ComponentFixture<PoolListComponent>;
+ let poolService: PoolService;
+ let getECPList: jasmine.Spy;
+
+ const getPoolList = (): Pool[] => {
+ return [Mocks.getPool('a', 0), Mocks.getPool('b', 1), Mocks.getPool('c', 2)];
+ };
+ const getECPProfiles = (): ErasureCodeProfile[] => {
+ const ecpProfile = new ErasureCodeProfile();
+ ecpProfile.name = 'default';
+ ecpProfile.k = 2;
+ ecpProfile.m = 1;
+
+ return [ecpProfile];
+ };
+
+ configureTestBed({
+ declarations: [PoolListComponent, PoolDetailsComponent, RbdConfigurationListComponent],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ RouterTestingModule,
+ NgbNavModule,
+ HttpClientTestingModule
+ ],
+ providers: [PgCategoryService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PoolListComponent);
+ component = fixture.componentInstance;
+ component.permissions.pool.read = true;
+ poolService = TestBed.inject(PoolService);
+ spyOn(poolService, 'getList').and.callFake(() => of(getPoolList()));
+ getECPList = spyOn(TestBed.inject(ErasureCodeProfileService), 'list');
+ getECPList.and.returnValue(of(getECPProfiles()));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have columns that are sortable', () => {
+ expect(
+ component.columns
+ .filter((column) => !(column.prop === undefined))
+ .every((column) => Boolean(column.prop))
+ ).toBeTruthy();
+ });
+
+ describe('monAllowPoolDelete', () => {
+ let configOptRead: boolean;
+ let configurationService: ConfigurationService;
+
+ beforeEach(() => {
+ configOptRead = true;
+ spyOn(TestBed.inject(AuthStorageService), 'getPermissions').and.callFake(() => ({
+ configOpt: { read: configOptRead }
+ }));
+ configurationService = TestBed.inject(ConfigurationService);
+ });
+
+ it('should set value correctly if mon_allow_pool_delete flag is set to true', () => {
+ const configOption = {
+ name: 'mon_allow_pool_delete',
+ value: [
+ {
+ section: 'mon',
+ value: 'true'
+ }
+ ]
+ };
+ spyOn(configurationService, 'get').and.returnValue(of(configOption));
+ fixture = TestBed.createComponent(PoolListComponent);
+ component = fixture.componentInstance;
+ expect(component.monAllowPoolDelete).toBe(true);
+ });
+
+ it('should set value correctly if mon_allow_pool_delete flag is set to false', () => {
+ const configOption = {
+ name: 'mon_allow_pool_delete',
+ value: [
+ {
+ section: 'mon',
+ value: 'false'
+ }
+ ]
+ };
+ spyOn(configurationService, 'get').and.returnValue(of(configOption));
+ fixture = TestBed.createComponent(PoolListComponent);
+ component = fixture.componentInstance;
+ expect(component.monAllowPoolDelete).toBe(false);
+ });
+
+ it('should set value correctly if mon_allow_pool_delete flag is not set', () => {
+ const configOption = {
+ name: 'mon_allow_pool_delete'
+ };
+ spyOn(configurationService, 'get').and.returnValue(of(configOption));
+ fixture = TestBed.createComponent(PoolListComponent);
+ component = fixture.componentInstance;
+ expect(component.monAllowPoolDelete).toBe(false);
+ });
+
+ it('should set value correctly w/o config-opt read privileges', () => {
+ configOptRead = false;
+ fixture = TestBed.createComponent(PoolListComponent);
+ component = fixture.componentInstance;
+ expect(component.monAllowPoolDelete).toBe(false);
+ });
+ });
+
+ describe('pool deletion', () => {
+ let taskWrapper: TaskWrapperService;
+ let modalRef: any;
+
+ const setSelectedPool = (poolName: string) =>
+ (component.selection.selected = [{ pool_name: poolName }]);
+
+ const callDeletion = () => {
+ component.deletePoolModal();
+ expect(modalRef).toBeTruthy();
+ const deletion: CriticalConfirmationModalComponent = modalRef && modalRef.componentInstance;
+ deletion.submitActionObservable();
+ };
+
+ const testPoolDeletion = (poolName: string) => {
+ setSelectedPool(poolName);
+ callDeletion();
+ expect(poolService.delete).toHaveBeenCalledWith(poolName);
+ expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
+ task: {
+ name: 'pool/delete',
+ metadata: {
+ pool_name: poolName
+ }
+ },
+ call: undefined // because of stub
+ });
+ };
+
+ beforeEach(() => {
+ spyOn(TestBed.inject(ModalService), 'show').and.callFake((deletionClass, initialState) => {
+ modalRef = {
+ componentInstance: Object.assign(new deletionClass(), initialState)
+ };
+ return modalRef;
+ });
+ spyOn(poolService, 'delete').and.stub();
+ taskWrapper = TestBed.inject(TaskWrapperService);
+ spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+ });
+
+ it('should pool deletion with two different pools', () => {
+ testPoolDeletion('somePoolName');
+ testPoolDeletion('aDifferentPoolName');
+ });
+ });
+
+ describe('handling of executing tasks', () => {
+ let summaryService: SummaryService;
+
+ const addTask = (name: string, pool: string) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ task.metadata = { pool_name: pool };
+ summaryService.addRunningTask(task);
+ };
+
+ beforeEach(() => {
+ summaryService = TestBed.inject(SummaryService);
+ summaryService['summaryDataSource'].next({
+ executing_tasks: [],
+ finished_tasks: []
+ });
+ });
+
+ it('gets all pools without executing pools', () => {
+ expect(component.pools.length).toBe(3);
+ expect(component.pools.every((pool) => !pool.executingTasks)).toBeTruthy();
+ });
+
+ it('gets a pool from a task during creation', () => {
+ addTask('pool/create', 'd');
+ expect(component.pools.length).toBe(4);
+ expectItemTasks(component.pools[3], 'Creating');
+ });
+
+ it('gets all pools with one executing pools', () => {
+ addTask('pool/create', 'a');
+ expect(component.pools.length).toBe(3);
+ expectItemTasks(component.pools[0], 'Creating');
+ expect(component.pools[1].cdExecuting).toBeFalsy();
+ expect(component.pools[2].cdExecuting).toBeFalsy();
+ });
+
+ it('gets all pools with multiple executing pools', () => {
+ addTask('pool/create', 'a');
+ addTask('pool/edit', 'a');
+ addTask('pool/delete', 'a');
+ addTask('pool/edit', 'b');
+ addTask('pool/delete', 'b');
+ addTask('pool/delete', 'c');
+ expect(component.pools.length).toBe(3);
+ expectItemTasks(component.pools[0], 'Creating..., Updating..., Deleting');
+ expectItemTasks(component.pools[1], 'Updating..., Deleting');
+ expectItemTasks(component.pools[2], 'Deleting');
+ });
+
+ it('gets all pools with multiple executing tasks (not only pool tasks)', () => {
+ addTask('rbd/create', 'a');
+ addTask('rbd/edit', 'a');
+ addTask('pool/delete', 'a');
+ addTask('pool/edit', 'b');
+ addTask('rbd/delete', 'b');
+ addTask('rbd/delete', 'c');
+ expect(component.pools.length).toBe(3);
+ expectItemTasks(component.pools[0], 'Deleting');
+ expectItemTasks(component.pools[1], 'Updating');
+ expect(component.pools[2].cdExecuting).toBeFalsy();
+ });
+ });
+
+ describe('getPgStatusCellClass', () => {
+ const testMethod = (value: string, expected: string) =>
+ expect(component.getPgStatusCellClass('', '', value)).toEqual({
+ 'text-right': true,
+ [expected]: true
+ });
+
+ it('pg-clean', () => {
+ testMethod('8 active+clean', 'pg-clean');
+ });
+
+ it('pg-working', () => {
+ testMethod(' 8 active+clean+scrubbing+deep, 255 active+clean ', 'pg-working');
+ });
+
+ it('pg-warning', () => {
+ testMethod('8 active+clean+scrubbing+down', 'pg-warning');
+ testMethod('8 active+clean+scrubbing+down+nonMappedState', 'pg-warning');
+ });
+
+ it('pg-unknown', () => {
+ testMethod('8 active+clean+scrubbing+nonMappedState', 'pg-unknown');
+ testMethod('8 ', 'pg-unknown');
+ testMethod('', 'pg-unknown');
+ });
+ });
+
+ describe('custom row comparators', () => {
+ const expectCorrectComparator = (statsAttribute: string) => {
+ const mockPool = (v: number) => ({ stats: { [statsAttribute]: { latest: v } } });
+ const columnDefinition = _.find(
+ component.columns,
+ (column) => column.prop === `stats.${statsAttribute}.rates`
+ );
+ expect(columnDefinition.comparator(undefined, undefined, mockPool(2), mockPool(1))).toBe(1);
+ expect(columnDefinition.comparator(undefined, undefined, mockPool(1), mockPool(2))).toBe(-1);
+ };
+
+ it('compares read bytes correctly', () => {
+ expectCorrectComparator('rd_bytes');
+ });
+
+ it('compares write bytes correctly', () => {
+ expectCorrectComparator('wr_bytes');
+ });
+ });
+
+ describe('transformPoolsData', () => {
+ let pool: Pool;
+
+ const getPoolData = (o: object) => [
+ _.merge(
+ _.merge(Mocks.getPool('a', 0), {
+ cdIsBinary: true,
+ pg_status: '',
+ stats: {
+ bytes_used: { latest: 0, rate: 0, rates: [] },
+ max_avail: { latest: 0, rate: 0, rates: [] },
+ avail_raw: { latest: 0, rate: 0, rates: [] },
+ percent_used: { latest: 0, rate: 0, rates: [] },
+ rd: { latest: 0, rate: 0, rates: [] },
+ rd_bytes: { latest: 0, rate: 0, rates: [] },
+ wr: { latest: 0, rate: 0, rates: [] },
+ wr_bytes: { latest: 0, rate: 0, rates: [] }
+ },
+ usage: 0,
+ data_protection: 'replica: ×3'
+ }),
+ o
+ )
+ ];
+
+ beforeEach(() => {
+ pool = Mocks.getPool('a', 0);
+ });
+
+ it('transforms replicated pools data correctly', () => {
+ pool = _.merge(pool, {
+ stats: {
+ bytes_used: { latest: 5, rate: 0, rates: [] },
+ avail_raw: { latest: 15, rate: 0, rates: [] },
+ percent_used: { latest: 0.25, rate: 0, rates: [] },
+ rd_bytes: {
+ latest: 6,
+ rate: 4,
+ rates: [
+ [0, 2],
+ [1, 6]
+ ]
+ }
+ },
+ pg_status: { 'active+clean': 8, down: 2 }
+ });
+ expect(component.transformPoolsData([pool])).toEqual(
+ getPoolData({
+ pg_status: '8 active+clean, 2 down',
+ stats: {
+ bytes_used: { latest: 5, rate: 0, rates: [] },
+ avail_raw: { latest: 15, rate: 0, rates: [] },
+ percent_used: { latest: 0.25, rate: 0, rates: [] },
+ rd_bytes: { latest: 6, rate: 4, rates: [2, 6] }
+ },
+ usage: 0.25,
+ data_protection: 'replica: ×3'
+ })
+ );
+ });
+
+ it('transforms erasure pools data correctly', () => {
+ pool.type = 'erasure';
+ pool.erasure_code_profile = 'default';
+ component.ecProfileList = getECPProfiles();
+
+ expect(component.transformPoolsData([pool])).toEqual(
+ getPoolData({
+ type: 'erasure',
+ erasure_code_profile: 'default',
+ data_protection: 'EC: 2+1'
+ })
+ );
+ });
+
+ it('transforms pools data correctly if stats are missing', () => {
+ expect(component.transformPoolsData([pool])).toEqual(getPoolData({}));
+ });
+
+ it('transforms empty pools data correctly', () => {
+ expect(component.transformPoolsData(undefined)).toEqual(undefined);
+ expect(component.transformPoolsData([])).toEqual([]);
+ });
+
+ it('shows not marked pools in progress if pg_num does not match pg_num_target', () => {
+ const pools = [
+ _.merge(pool, {
+ pg_num: 32,
+ pg_num_target: 16,
+ pg_placement_num: 32,
+ pg_placement_num_target: 16
+ })
+ ];
+ expect(component.transformPoolsData(pools)).toEqual(
+ getPoolData({
+ cdExecuting: 'Updating',
+ pg_num: 32,
+ pg_num_target: 16,
+ pg_placement_num: 32,
+ pg_placement_num_target: 16,
+ data_protection: 'replica: ×3'
+ })
+ );
+ });
+
+ it('shows marked pools in progress as defined by task', () => {
+ const pools = [
+ _.merge(pool, {
+ pg_num: 32,
+ pg_num_target: 16,
+ pg_placement_num: 32,
+ pg_placement_num_target: 16,
+ cdExecuting: 'Updating... 50%'
+ })
+ ];
+ expect(component.transformPoolsData(pools)).toEqual(
+ getPoolData({
+ cdExecuting: 'Updating... 50%',
+ pg_num: 32,
+ pg_num_target: 16,
+ pg_placement_num: 32,
+ pg_placement_num_target: 16,
+ data_protection: 'replica: ×3'
+ })
+ );
+ });
+ });
+
+ describe('transformPgStatus', () => {
+ it('returns status groups correctly', () => {
+ const pgStatus = { 'active+clean': 8 };
+ const expected = '8 active+clean';
+
+ expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+ });
+
+ it('returns separated status groups', () => {
+ const pgStatus = { 'active+clean': 8, down: 2 };
+ const expected = '8 active+clean, 2 down';
+
+ expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+ });
+
+ it('returns separated statuses correctly', () => {
+ const pgStatus = { active: 8, down: 2 };
+ const expected = '8 active, 2 down';
+
+ expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+ });
+
+ it('returns empty string', () => {
+ const pgStatus: any = undefined;
+ const expected = '';
+
+ expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+ });
+ });
+
+ describe('getSelectionTiers', () => {
+ const setSelectionTiers = (tiers: number[]) => {
+ component.expandedRow = { tiers };
+ component.getSelectionTiers();
+ };
+
+ beforeEach(() => {
+ component.pools = getPoolList();
+ });
+
+ it('should select multiple existing cache tiers', () => {
+ setSelectionTiers([0, 1, 2]);
+ expect(component.cacheTiers).toEqual(getPoolList());
+ });
+
+ it('should select correct existing cache tier', () => {
+ setSelectionTiers([0]);
+ expect(component.cacheTiers).toEqual([Mocks.getPool('a', 0)]);
+ });
+
+ it('should not select cache tier if id is invalid', () => {
+ setSelectionTiers([-1]);
+ expect(component.cacheTiers).toEqual([]);
+ });
+
+ it('should not select cache tier if empty', () => {
+ setSelectionTiers([]);
+ expect(component.cacheTiers).toEqual([]);
+ });
+
+ it('should be able to selected one pool with multiple tiers, than with a single tier, than with no tiers', () => {
+ setSelectionTiers([0, 1, 2]);
+ expect(component.cacheTiers).toEqual(getPoolList());
+ setSelectionTiers([0]);
+ expect(component.cacheTiers).toEqual([Mocks.getPool('a', 0)]);
+ setSelectionTiers([]);
+ expect(component.cacheTiers).toEqual([]);
+ });
+ });
+
+ describe('getDisableDesc', () => {
+ beforeEach(() => {
+ component.selection.selected = [{ pool_name: 'foo' }];
+ });
+
+ it('should return message if mon_allow_pool_delete flag is set to false', () => {
+ component.monAllowPoolDelete = false;
+ expect(component.getDisableDesc()).toBe(
+ 'Pool deletion is disabled by the mon_allow_pool_delete configuration setting.'
+ );
+ });
+
+ it('should return false if mon_allow_pool_delete flag is set to true', () => {
+ component.monAllowPoolDelete = true;
+ expect(component.getDisableDesc()).toBeFalsy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts
new file mode 100644
index 000000000..ba2d9cbe5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts
@@ -0,0 +1,332 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import _ from 'lodash';
+import { mergeMap } from 'rxjs/operators';
+
+import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+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';
+import { ViewCacheStatus } from '~/app/shared/enum/view-cache-status.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Permissions } from '~/app/shared/models/permissions';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskListService } from '~/app/shared/services/task-list.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { Pool } from '../pool';
+import { PoolStat, PoolStats } from '../pool-stat';
+
+const BASE_URL = 'pool';
+
+@Component({
+ selector: 'cd-pool-list',
+ templateUrl: './pool-list.component.html',
+ providers: [
+ TaskListService,
+ { provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }
+ ],
+ styleUrls: ['./pool-list.component.scss']
+})
+export class PoolListComponent extends ListWithDetails implements OnInit {
+ @ViewChild(TableComponent)
+ table: TableComponent;
+ @ViewChild('poolUsageTpl', { static: true })
+ poolUsageTpl: TemplateRef<any>;
+
+ @ViewChild('poolConfigurationSourceTpl')
+ poolConfigurationSourceTpl: TemplateRef<any>;
+
+ pools: Pool[];
+ columns: CdTableColumn[];
+ selection = new CdTableSelection();
+ executingTasks: ExecutingTask[] = [];
+ permissions: Permissions;
+ tableActions: CdTableAction[];
+ tableStatus = new TableStatusViewCache();
+ cacheTiers: any[] = [];
+ monAllowPoolDelete = false;
+ ecProfileList: ErasureCodeProfile[];
+
+ constructor(
+ private poolService: PoolService,
+ private taskWrapper: TaskWrapperService,
+ private ecpService: ErasureCodeProfileService,
+ private authStorageService: AuthStorageService,
+ public taskListService: TaskListService,
+ private modalService: ModalService,
+ private pgCategoryService: PgCategoryService,
+ private dimlessPipe: DimlessPipe,
+ private urlBuilder: URLBuilderService,
+ private configurationService: ConfigurationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.permissions = this.authStorageService.getPermissions();
+ this.tableActions = [
+ {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ name: this.actionLabels.CREATE
+ },
+ {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () =>
+ this.urlBuilder.getEdit(encodeURIComponent(this.selection.first().pool_name)),
+ name: this.actionLabels.EDIT
+ },
+ {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deletePoolModal(),
+ name: this.actionLabels.DELETE,
+ disable: this.getDisableDesc.bind(this)
+ }
+ ];
+
+ // Note, we need read permissions to get the 'mon_allow_pool_delete'
+ // configuration option.
+ if (this.permissions.configOpt.read) {
+ this.configurationService.get('mon_allow_pool_delete').subscribe((data: any) => {
+ if (_.has(data, 'value')) {
+ const monSection = _.find(data.value, (v) => {
+ return v.section === 'mon';
+ }) || { value: false };
+ this.monAllowPoolDelete = monSection.value === 'true' ? true : false;
+ }
+ });
+ }
+ }
+
+ ngOnInit() {
+ const compare = (prop: string, pool1: Pool, pool2: Pool) =>
+ _.get(pool1, prop) > _.get(pool2, prop) ? 1 : -1;
+ this.columns = [
+ {
+ prop: 'pool_name',
+ name: $localize`Name`,
+ flexGrow: 4,
+ cellTransformation: CellTemplate.executing
+ },
+ {
+ prop: 'data_protection',
+ name: $localize`Data Protection`,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ class: 'badge-background-gray'
+ },
+ flexGrow: 1.3
+ },
+ {
+ prop: 'application_metadata',
+ name: $localize`Applications`,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ class: 'badge-background-primary'
+ },
+ flexGrow: 1.5
+ },
+ {
+ prop: 'pg_status',
+ name: $localize`PG Status`,
+ flexGrow: 1.2,
+ cellClass: ({ row, column, value }): any => {
+ return this.getPgStatusCellClass(row, column, value);
+ }
+ },
+ {
+ prop: 'crush_rule',
+ name: $localize`Crush Ruleset`,
+ isHidden: true,
+ flexGrow: 2
+ },
+ {
+ name: $localize`Usage`,
+ prop: 'usage',
+ cellTemplate: this.poolUsageTpl,
+ flexGrow: 1.2
+ },
+ {
+ prop: 'stats.rd_bytes.rates',
+ name: $localize`Read bytes`,
+ comparator: (_valueA: any, _valueB: any, rowA: Pool, rowB: Pool) =>
+ compare('stats.rd_bytes.latest', rowA, rowB),
+ cellTransformation: CellTemplate.sparkline,
+ flexGrow: 1.5
+ },
+ {
+ prop: 'stats.wr_bytes.rates',
+ name: $localize`Write bytes`,
+ comparator: (_valueA: any, _valueB: any, rowA: Pool, rowB: Pool) =>
+ compare('stats.wr_bytes.latest', rowA, rowB),
+ cellTransformation: CellTemplate.sparkline,
+ flexGrow: 1.5
+ },
+ {
+ prop: 'stats.rd.rate',
+ name: $localize`Read ops`,
+ flexGrow: 1,
+ pipe: this.dimlessPipe,
+ cellTransformation: CellTemplate.perSecond
+ },
+ {
+ prop: 'stats.wr.rate',
+ name: $localize`Write ops`,
+ flexGrow: 1,
+ pipe: this.dimlessPipe,
+ cellTransformation: CellTemplate.perSecond
+ }
+ ];
+
+ this.taskListService.init(
+ () =>
+ this.ecpService.list().pipe(
+ mergeMap((ecProfileList: ErasureCodeProfile[]) => {
+ this.ecProfileList = ecProfileList;
+ return this.poolService.getList();
+ })
+ ),
+ undefined,
+ (pools) => {
+ this.pools = this.transformPoolsData(pools);
+ this.tableStatus = new TableStatusViewCache();
+ },
+ () => {
+ this.table.reset(); // Disable loading indicator.
+ this.tableStatus = new TableStatusViewCache(ViewCacheStatus.ValueException);
+ },
+ (task) => task.name.startsWith(`${BASE_URL}/`),
+ (pool, task) => task.metadata['pool_name'] === pool.pool_name,
+ { default: (metadata: any) => new Pool(metadata['pool_name']) }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deletePoolModal() {
+ const name = this.selection.first().pool_name;
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'Pool',
+ itemNames: [name],
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask(`${BASE_URL}/${URLVerbs.DELETE}`, { pool_name: name }),
+ call: this.poolService.delete(name)
+ })
+ });
+ }
+
+ getPgStatusCellClass(_row: any, _column: any, value: string): object {
+ return {
+ 'text-right': true,
+ [`pg-${this.pgCategoryService.getTypeByStates(value)}`]: true
+ };
+ }
+
+ getErasureCodeProfile(erasureCodeProfile: string) {
+ let ecpInfo = '';
+ _.forEach(this.ecProfileList, (ecpKey) => {
+ if (ecpKey['name'] === erasureCodeProfile) {
+ ecpInfo = `EC: ${ecpKey['k']}+${ecpKey['m']}`;
+ }
+ });
+ return ecpInfo;
+ }
+
+ transformPoolsData(pools: any) {
+ const requiredStats = [
+ 'bytes_used',
+ 'max_avail',
+ 'avail_raw',
+ 'percent_used',
+ 'rd_bytes',
+ 'wr_bytes',
+ 'rd',
+ 'wr'
+ ];
+ const emptyStat: PoolStat = { latest: 0, rate: 0, rates: [] };
+
+ _.forEach(pools, (pool: Pool) => {
+ pool['pg_status'] = this.transformPgStatus(pool['pg_status']);
+ const stats: PoolStats = {};
+ _.forEach(requiredStats, (stat) => {
+ stats[stat] = pool.stats && pool.stats[stat] ? pool.stats[stat] : emptyStat;
+ });
+ pool['stats'] = stats;
+ pool['usage'] = stats.percent_used.latest;
+
+ if (
+ !pool.cdExecuting &&
+ pool.pg_num + pool.pg_placement_num !== pool.pg_num_target + pool.pg_placement_num_target
+ ) {
+ pool['cdExecuting'] = 'Updating';
+ }
+
+ ['rd_bytes', 'wr_bytes'].forEach((stat) => {
+ pool.stats[stat].rates = pool.stats[stat].rates.map((point: any) => point[1]);
+ });
+ pool.cdIsBinary = true;
+
+ if (pool['type'] === 'erasure') {
+ const erasureCodeProfile = pool['erasure_code_profile'];
+ pool['data_protection'] = this.getErasureCodeProfile(erasureCodeProfile);
+ }
+ if (pool['type'] === 'replicated') {
+ pool['data_protection'] = `replica: ×${pool['size']}`;
+ }
+ });
+
+ return pools;
+ }
+
+ transformPgStatus(pgStatus: any): string {
+ const strings: string[] = [];
+ _.forEach(pgStatus, (count, state) => {
+ strings.push(`${count} ${state}`);
+ });
+
+ return strings.join(', ');
+ }
+
+ getSelectionTiers() {
+ if (typeof this.expandedRow !== 'undefined') {
+ const cacheTierIds = this.expandedRow['tiers'];
+ this.cacheTiers = this.pools.filter((pool) => cacheTierIds.includes(pool.pool));
+ }
+ }
+
+ getDisableDesc(): boolean | string {
+ if (this.selection?.hasSelection) {
+ if (!this.monAllowPoolDelete) {
+ return $localize`Pool deletion is disabled by the mon_allow_pool_delete configuration setting.`;
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ setExpandedRow(expandedRow: any) {
+ super.setExpandedRow(expandedRow);
+ this.getSelectionTiers();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-stat.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-stat.ts
new file mode 100644
index 000000000..9820be94a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-stat.ts
@@ -0,0 +1,16 @@
+export class PoolStat {
+ latest: number;
+ rate: number;
+ rates: number[];
+}
+
+export class PoolStats {
+ bytes_used?: PoolStat;
+ max_avail?: PoolStat;
+ avail_raw?: PoolStat;
+ percent_used?: PoolStat;
+ rd_bytes?: PoolStat;
+ wr_bytes?: PoolStat;
+ rd?: PoolStat;
+ wr?: PoolStat;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
new file mode 100644
index 000000000..3f01b9fd9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
@@ -0,0 +1,57 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterModule, Routes } from '@angular/router';
+
+import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants';
+import { SharedModule } from '~/app/shared/shared.module';
+import { BlockModule } from '../block/block.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
+import { CrushRuleFormModalComponent } from './crush-rule-form-modal/crush-rule-form-modal.component';
+import { ErasureCodeProfileFormModalComponent } from './erasure-code-profile-form/erasure-code-profile-form-modal.component';
+import { PoolDetailsComponent } from './pool-details/pool-details.component';
+import { PoolFormComponent } from './pool-form/pool-form.component';
+import { PoolListComponent } from './pool-list/pool-list.component';
+
+@NgModule({
+ imports: [
+ CephSharedModule,
+ CommonModule,
+ NgbNavModule,
+ SharedModule,
+ RouterModule,
+ ReactiveFormsModule,
+ NgbTooltipModule,
+ BlockModule
+ ],
+ exports: [PoolListComponent, PoolFormComponent],
+ declarations: [
+ PoolListComponent,
+ PoolFormComponent,
+ ErasureCodeProfileFormModalComponent,
+ CrushRuleFormModalComponent,
+ PoolDetailsComponent
+ ]
+})
+export class PoolModule {}
+
+const routes: Routes = [
+ { path: '', component: PoolListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: PoolFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:name`,
+ component: PoolFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+];
+
+@NgModule({
+ imports: [PoolModule, RouterModule.forChild(routes)]
+})
+export class RoutedPoolModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts
new file mode 100644
index 000000000..55c70c6f5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts
@@ -0,0 +1,73 @@
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { PoolStats } from './pool-stat';
+
+export class Pool {
+ cache_target_full_ratio_micro: number;
+ fast_read: boolean;
+ stripe_width: number;
+ flags_names: string;
+ tier_of: number;
+ hit_set_grade_decay_rate: number;
+ use_gmt_hitset: boolean;
+ last_force_op_resend_preluminous: string;
+ quota_max_bytes: number;
+ erasure_code_profile: string;
+ expected_num_objects: number;
+ size: number;
+ snap_seq: number;
+ auid: number;
+ cache_min_flush_age: number;
+ hit_set_period: number;
+ min_read_recency_for_promote: number;
+ target_max_objects: number;
+ pg_num: number;
+ pg_num_target: number;
+ pg_num_pending: number;
+ pg_placement_num: number;
+ pg_placement_num_target: number;
+ pg_autoscale_mode: string;
+ pg_status: string;
+ type: string;
+ pool_name: string;
+ cache_min_evict_age: number;
+ cache_mode: string;
+ min_size: number;
+ cache_target_dirty_high_ratio_micro: number;
+ object_hash: number;
+ application_metadata: string[];
+ write_tier: number;
+ cache_target_dirty_ratio_micro: number;
+ pool: number;
+ removed_snaps: string;
+ cdExecuting?: string;
+ executingTasks?: ExecutingTask[];
+ crush_rule: string;
+ tiers: any[];
+ hit_set_params: {
+ type: string;
+ };
+ last_force_op_resend: string;
+ pool_snaps: any[];
+ quota_max_objects: number;
+ options: {
+ compression_algorithm?: string;
+ compression_max_blob_size?: number;
+ compression_min_blob_size?: number;
+ compression_mode?: string;
+ compression_required_ratio?: number;
+ };
+ hit_set_count: number;
+ flags: number;
+ target_max_bytes: number;
+ hit_set_search_last_n: number;
+ last_change: string;
+ min_write_recency_for_promote: number;
+ read_tier: number;
+ stats?: PoolStats;
+ cdIsBinary?: boolean;
+ configuration: { source: number; name: string; value: string }[];
+
+ constructor(name: string) {
+ this.pool_name = name;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts
new file mode 100644
index 000000000..531094087
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts
@@ -0,0 +1,4 @@
+export enum RgwBucketMfaDelete {
+ ENABLED = 'Enabled',
+ DISABLED = 'Disabled'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts
new file mode 100644
index 000000000..51048c65e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts
@@ -0,0 +1,4 @@
+export enum RgwBucketVersioning {
+ ENABLED = 'Enabled',
+ SUSPENDED = 'Suspended'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts
new file mode 100644
index 000000000..445f2a5ac
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-daemon.ts
@@ -0,0 +1,10 @@
+export class RgwDaemon {
+ id: string;
+ service_map_id: string;
+ version: string;
+ server_hostname: string;
+ realm_name: string;
+ zonegroup_name: string;
+ zone_name: string;
+ default: boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capabilities.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capabilities.ts
new file mode 100644
index 000000000..dac6986c7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capabilities.ts
@@ -0,0 +1,15 @@
+export enum RgwUserAvailableCapability {
+ USERS = 'users',
+ BUCKETS = 'buckets',
+ METADATA = 'metadata',
+ USAGE = 'usage',
+ ZONE = 'zone'
+}
+
+export class RgwUserCapabilities {
+ static readonly capabilities = RgwUserAvailableCapability;
+
+ static getAll(): string[] {
+ return Object.values(RgwUserCapabilities.capabilities);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts
new file mode 100644
index 000000000..ee10088c0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts
@@ -0,0 +1,4 @@
+export class RgwUserCapability {
+ type: string;
+ perm: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts
new file mode 100644
index 000000000..bcb953106
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts
@@ -0,0 +1,6 @@
+export class RgwUserS3Key {
+ user: string;
+ generate_key?: boolean;
+ access_key: string;
+ secret_key: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts
new file mode 100644
index 000000000..788b6a291
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts
@@ -0,0 +1,6 @@
+export class RgwUserSubuser {
+ id: string;
+ permissions: string;
+ generate_secret?: boolean;
+ secret_key?: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts
new file mode 100644
index 000000000..26abd2a99
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts
@@ -0,0 +1,4 @@
+export class RgwUserSwiftKey {
+ user: string;
+ secret_key: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
new file mode 100644
index 000000000..bf4bdcb08
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
@@ -0,0 +1,127 @@
+<ng-container *ngIf="selection">
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Name</td>
+ <td class="w-75">{{ selection.bid }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">ID</td>
+ <td>{{ selection.id }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Owner</td>
+ <td>{{ selection.owner }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Index type</td>
+ <td>{{ selection.index_type }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Placement rule</td>
+ <td>{{ selection.placement_rule }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Marker</td>
+ <td>{{ selection.marker }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum marker</td>
+ <td>{{ selection.max_marker }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Version</td>
+ <td>{{ selection.ver }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Master version</td>
+ <td>{{ selection.master_ver }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Modification time</td>
+ <td>{{ selection.mtime | cdDate }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Zonegroup</td>
+ <td>{{ selection.zonegroup }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Versioning</td>
+ <td>{{ selection.versioning }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">MFA Delete</td>
+ <td>{{ selection.mfa_delete }}</td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- Bucket quota -->
+ <div *ngIf="selection.bucket_quota">
+ <legend i18n>Bucket quota</legend>
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Enabled</td>
+ <td class="w-75">{{ selection.bucket_quota.enabled | booleanText }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum size</td>
+ <td *ngIf="selection.bucket_quota.max_size <= -1"
+ i18n>Unlimited</td>
+ <td *ngIf="selection.bucket_quota.max_size > -1">
+ {{ selection.bucket_quota.max_size | dimless }}
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum objects</td>
+ <td *ngIf="selection.bucket_quota.max_objects <= -1"
+ i18n>Unlimited</td>
+ <td *ngIf="selection.bucket_quota.max_objects > -1">
+ {{ selection.bucket_quota.max_objects }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <!-- Locking -->
+ <legend i18n>Locking</legend>
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Enabled</td>
+ <td class="w-75">{{ selection.lock_enabled | booleanText }}</td>
+ </tr>
+ <ng-container *ngIf="selection.lock_enabled">
+ <tr>
+ <td i18n
+ class="bold">Mode</td>
+ <td>{{ selection.lock_mode }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Days</td>
+ <td>{{ selection.lock_retention_period_days }}</td>
+ </tr>
+ </ng-container>
+ </tbody>
+ </table>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss
new file mode 100644
index 000000000..d293c9d98
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.scss
@@ -0,0 +1,7 @@
+table {
+ table-layout: fixed;
+}
+
+table td {
+ word-wrap: break-word;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.spec.ts
new file mode 100644
index 000000000..ca6e09f0c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.spec.ts
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { of } from 'rxjs';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwBucketDetailsComponent } from './rgw-bucket-details.component';
+
+describe('RgwBucketDetailsComponent', () => {
+ let component: RgwBucketDetailsComponent;
+ let fixture: ComponentFixture<RgwBucketDetailsComponent>;
+ let rgwBucketService: RgwBucketService;
+ let rgwBucketServiceGetSpy: jasmine.Spy;
+
+ configureTestBed({
+ declarations: [RgwBucketDetailsComponent],
+ imports: [SharedModule, HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ rgwBucketService = TestBed.inject(RgwBucketService);
+ rgwBucketServiceGetSpy = spyOn(rgwBucketService, 'get');
+ rgwBucketServiceGetSpy.and.returnValue(of(null));
+ fixture = TestBed.createComponent(RgwBucketDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = new CdTableSelection();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should retrieve bucket full info', () => {
+ component.selection = { bid: 'bucket' };
+ component.ngOnChanges();
+ expect(rgwBucketServiceGetSpy).toHaveBeenCalled();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts
new file mode 100644
index 000000000..f9a351367
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts
@@ -0,0 +1,24 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+
+@Component({
+ selector: 'cd-rgw-bucket-details',
+ templateUrl: './rgw-bucket-details.component.html',
+ styleUrls: ['./rgw-bucket-details.component.scss']
+})
+export class RgwBucketDetailsComponent implements OnChanges {
+ @Input()
+ selection: any;
+
+ constructor(private rgwBucketService: RgwBucketService) {}
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.rgwBucketService.get(this.selection.bid).subscribe((bucket: object) => {
+ bucket['lock_retention_period_days'] = this.rgwBucketService.getLockDays(bucket);
+ this.selection = bucket;
+ });
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
new file mode 100644
index 000000000..bad80a7f3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
@@ -0,0 +1,291 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="bucketForm"
+ #frm="ngForm"
+ [formGroup]="bucketForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- Id -->
+ <div class="form-group row"
+ *ngIf="editing">
+ <label i18n
+ class="cd-col-form-label"
+ for="id">Id</label>
+ <div class="cd-col-form-input">
+ <input id="id"
+ name="id"
+ class="form-control"
+ type="text"
+ formControlName="id"
+ readonly>
+ </div>
+ </div>
+
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{required: !editing}"
+ for="bid"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input id="bid"
+ name="bid"
+ class="form-control"
+ type="text"
+ i18n-placeholder
+ placeholder="Name..."
+ formControlName="bid"
+ [readonly]="editing"
+ [autofocus]="!editing">
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'bucketNameInvalid')"
+ i18n>Bucket names can only contain lowercase letters, numbers, periods and hyphens.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'bucketNameNotAllowed')"
+ i18n>The chosen name is already in use.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'containsUpperCase')"
+ i18n>Bucket names must not contain uppercase characters or underscores.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'lowerCaseOrNumber')"
+ i18n>Each label must start and end with a lowercase letter or a number.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'ipAddress')"
+ i18n>Bucket names cannot be formatted as IP address.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'onlyLowerCaseAndNumbers')"
+ i18n>Bucket labels cannot be empty and can only contain lowercase letters, numbers and hyphens.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('bid', frm, 'shouldBeInRange')"
+ i18n>Bucket names must be 3 to 63 characters long.</span>
+ </div>
+ </div>
+
+ <!-- Owner -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="owner"
+ i18n>Owner</label>
+ <div class="cd-col-form-input">
+ <select id="owner"
+ name="owner"
+ class="form-control"
+ formControlName="owner"
+ [autofocus]="editing">
+ <option i18n
+ *ngIf="owners === null"
+ [ngValue]="null">Loading...</option>
+ <option i18n
+ *ngIf="owners !== null"
+ [ngValue]="null">-- Select a user --</option>
+ <option *ngFor="let owner of owners"
+ [value]="owner">{{ owner }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('owner', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Placement target -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{required: !editing}"
+ for="placement-target"
+ i18n>Placement target</label>
+ <div class="cd-col-form-input">
+ <ng-template #placementTargetSelect>
+ <select id="placement-target"
+ name="placement-target"
+ formControlName="placement-target"
+ class="form-control">
+ <option i18n
+ *ngIf="placementTargets === null"
+ [ngValue]="null">Loading...</option>
+ <option i18n
+ *ngIf="placementTargets !== null"
+ [ngValue]="null">-- Select a placement target --</option>
+ <option *ngFor="let placementTarget of placementTargets"
+ [value]="placementTarget.name">{{ placementTarget.description }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('placement-target', frm, 'required')"
+ i18n>This field is required.</span>
+ </ng-template>
+ <ng-container *ngIf="editing; else placementTargetSelect">
+ <input id="placement-target"
+ name="placement-target"
+ formControlName="placement-target"
+ class="form-control"
+ type="text"
+ readonly>
+ </ng-container>
+ </div>
+ </div>
+
+ <!-- Versioning -->
+ <fieldset *ngIf="editing">
+ <legend class="cd-header"
+ i18n>Versioning</legend>
+
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="versioning"
+ name="versioning"
+ formControlName="versioning"
+ (change)="setMfaDeleteValidators()">
+ <label class="custom-control-label"
+ for="versioning"
+ i18n>Enabled</label>
+ <cd-helper>
+ <span i18n>Enables versioning for the objects in the bucket.</span>
+ </cd-helper>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Multi-Factor Authentication -->
+ <fieldset *ngIf="editing">
+ <!-- MFA Delete -->
+ <legend class="cd-header"
+ i18n>Multi-Factor Authentication</legend>
+
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="mfa-delete"
+ name="mfa-delete"
+ formControlName="mfa-delete"
+ (change)="setMfaDeleteValidators()">
+ <label class="custom-control-label"
+ for="mfa-delete"
+ i18n>Delete enabled</label>
+ <cd-helper>
+ <span i18n>Enables MFA (multi-factor authentication) Delete, which requires additional authentication for changing the bucket versioning state.</span>
+ </cd-helper>
+ </div>
+ </div>
+ </div>
+ <div *ngIf="areMfaCredentialsRequired()"
+ class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="mfa-token-serial">Token Serial Number</label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ id="mfa-token-serial"
+ name="mfa-token-serial"
+ formControlName="mfa-token-serial"
+ class="form-control">
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('mfa-token-serial', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <div *ngIf="areMfaCredentialsRequired()"
+ class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="mfa-token-pin">Token PIN</label>
+ <div class="cd-col-form-input">
+ <input type="text"
+ id="mfa-token-pin"
+ name="mfa-token-pin"
+ formControlName="mfa-token-pin"
+ class="form-control">
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('mfa-token-pin', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Locking -->
+ <fieldset>
+ <legend class="cd-header"
+ i18n>Locking</legend>
+
+ <!-- Locking enabled -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="lock_enabled"
+ formControlName="lock_enabled"
+ type="checkbox">
+ <label class="custom-control-label"
+ for="lock_enabled"
+ i18n>Enabled</label>
+ <cd-helper>
+ <span i18n>Enables locking for the objects in the bucket. Locking can only be enabled while creating a bucket.</span>
+ </cd-helper>
+ </div>
+ </div>
+ </div>
+
+ <!-- Locking mode -->
+ <div *ngIf="bucketForm.getValue('lock_enabled')"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="lock_mode"
+ i18n>Mode</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="lock_mode"
+ name="lock_mode"
+ id="lock_mode">
+ <option i18n
+ value="COMPLIANCE">Compliance</option>
+ <option i18n
+ value="GOVERNANCE">Governance</option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Retention period (days) -->
+ <div *ngIf="bucketForm.getValue('lock_enabled')"
+ class="form-group row">
+ <label class="cd-col-form-label"
+ for="lock_retention_period_days">
+ <ng-container i18n>Days</ng-container>
+ <cd-helper i18n>The number of days that you want to specify for the default retention period that will be applied to new objects placed in this bucket.</cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="number"
+ id="lock_retention_period_days"
+ formControlName="lock_retention_period_days"
+ min="0">
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('lock_retention_period_days', frm, 'pattern')"
+ i18n>The entered value must be a positive integer.</span>
+ <span class="invalid-feedback"
+ *ngIf="bucketForm.showError('lock_retention_period_days', frm, 'lockDays')"
+ i18n>Retention Days must be a positive integer.</span>
+ </div>
+ </div>
+ </fieldset>
+
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="bucketForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts
new file mode 100644
index 000000000..704d79184
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts
@@ -0,0 +1,300 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
+import { of as observableOf } from 'rxjs';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { RgwSiteService } from '~/app/shared/api/rgw-site.service';
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
+import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
+import { RgwBucketFormComponent } from './rgw-bucket-form.component';
+
+describe('RgwBucketFormComponent', () => {
+ let component: RgwBucketFormComponent;
+ let fixture: ComponentFixture<RgwBucketFormComponent>;
+ let rgwBucketService: RgwBucketService;
+ let getPlacementTargetsSpy: jasmine.Spy;
+ let rgwBucketServiceGetSpy: jasmine.Spy;
+ let enumerateSpy: jasmine.Spy;
+ let formHelper: FormHelper;
+
+ configureTestBed({
+ declarations: [RgwBucketFormComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwBucketFormComponent);
+ component = fixture.componentInstance;
+ rgwBucketService = TestBed.inject(RgwBucketService);
+ rgwBucketServiceGetSpy = spyOn(rgwBucketService, 'get');
+ getPlacementTargetsSpy = spyOn(TestBed.inject(RgwSiteService), 'get');
+ enumerateSpy = spyOn(TestBed.inject(RgwUserService), 'enumerate');
+ formHelper = new FormHelper(component.bucketForm);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('bucketNameValidator', () => {
+ it('should validate empty name', fakeAsync(() => {
+ formHelper.expectErrorChange('bid', '', 'required', true);
+ }));
+ });
+
+ describe('zonegroup and placement targets', () => {
+ it('should get zonegroup and placement targets', () => {
+ const payload: Record<string, any> = {
+ zonegroup: 'default',
+ placement_targets: [
+ {
+ name: 'default-placement',
+ data_pool: 'default.rgw.buckets.data'
+ },
+ {
+ name: 'placement-target2',
+ data_pool: 'placement-target2.rgw.buckets.data'
+ }
+ ]
+ };
+ getPlacementTargetsSpy.and.returnValue(observableOf(payload));
+ enumerateSpy.and.returnValue(observableOf([]));
+ fixture.detectChanges();
+
+ expect(component.zonegroup).toBe(payload.zonegroup);
+ const placementTargets = [];
+ for (const placementTarget of payload['placement_targets']) {
+ placementTarget[
+ 'description'
+ ] = `${placementTarget['name']} (pool: ${placementTarget['data_pool']})`;
+ placementTargets.push(placementTarget);
+ }
+ expect(component.placementTargets).toEqual(placementTargets);
+ });
+ });
+
+ describe('submit form', () => {
+ let notificationService: NotificationService;
+
+ beforeEach(() => {
+ spyOn(TestBed.inject(Router), 'navigate').and.stub();
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show');
+ });
+
+ it('should validate name', () => {
+ component.editing = false;
+ component.createForm();
+ const control = component.bucketForm.get('bid');
+ expect(_.isFunction(control.asyncValidator)).toBeTruthy();
+ });
+
+ it('should not validate name', () => {
+ component.editing = true;
+ component.createForm();
+ const control = component.bucketForm.get('bid');
+ expect(control.asyncValidator).toBeNull();
+ });
+
+ it('tests create success notification', () => {
+ spyOn(rgwBucketService, 'create').and.returnValue(observableOf([]));
+ component.editing = false;
+ component.bucketForm.markAsDirty();
+ component.submit();
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `Created Object Gateway bucket 'null'`
+ );
+ });
+
+ it('tests update success notification', () => {
+ spyOn(rgwBucketService, 'update').and.returnValue(observableOf([]));
+ component.editing = true;
+ component.bucketForm.markAsDirty();
+ component.submit();
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `Updated Object Gateway bucket 'null'.`
+ );
+ });
+ });
+
+ describe('mfa credentials', () => {
+ const checkMfaCredentialsVisibility = (
+ fakeResponse: object,
+ versioningChecked: boolean,
+ mfaDeleteChecked: boolean,
+ expectedVisibility: boolean
+ ) => {
+ component['route'].params = observableOf({ bid: 'bid' });
+ component.editing = true;
+ rgwBucketServiceGetSpy.and.returnValue(observableOf(fakeResponse));
+ enumerateSpy.and.returnValue(observableOf([]));
+ component.ngOnInit();
+ component.bucketForm.patchValue({
+ versioning: versioningChecked,
+ 'mfa-delete': mfaDeleteChecked
+ });
+ fixture.detectChanges();
+
+ const mfaTokenSerial = fixture.debugElement.nativeElement.querySelector('#mfa-token-serial');
+ const mfaTokenPin = fixture.debugElement.nativeElement.querySelector('#mfa-token-pin');
+ if (expectedVisibility) {
+ expect(mfaTokenSerial).toBeTruthy();
+ expect(mfaTokenPin).toBeTruthy();
+ } else {
+ expect(mfaTokenSerial).toBeFalsy();
+ expect(mfaTokenPin).toBeFalsy();
+ }
+ };
+
+ it('inputs should be visible when required', () => {
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.SUSPENDED,
+ mfa_delete: RgwBucketMfaDelete.DISABLED
+ },
+ false,
+ false,
+ false
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.SUSPENDED,
+ mfa_delete: RgwBucketMfaDelete.DISABLED
+ },
+ true,
+ false,
+ false
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.ENABLED,
+ mfa_delete: RgwBucketMfaDelete.DISABLED
+ },
+ false,
+ false,
+ false
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.ENABLED,
+ mfa_delete: RgwBucketMfaDelete.ENABLED
+ },
+ true,
+ true,
+ false
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.SUSPENDED,
+ mfa_delete: RgwBucketMfaDelete.DISABLED
+ },
+ false,
+ true,
+ true
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.SUSPENDED,
+ mfa_delete: RgwBucketMfaDelete.ENABLED
+ },
+ false,
+ false,
+ true
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.SUSPENDED,
+ mfa_delete: RgwBucketMfaDelete.ENABLED
+ },
+ true,
+ true,
+ true
+ );
+ checkMfaCredentialsVisibility(
+ {
+ versioning: RgwBucketVersioning.ENABLED,
+ mfa_delete: RgwBucketMfaDelete.ENABLED
+ },
+ false,
+ true,
+ true
+ );
+ });
+ });
+
+ describe('object locking', () => {
+ const expectPatternLockError = (value: string) => {
+ formHelper.setValue('lock_enabled', true, true);
+ formHelper.setValue('lock_retention_period_days', value);
+ formHelper.expectError('lock_retention_period_days', 'pattern');
+ };
+
+ const expectValidLockInputs = (enabled: boolean, mode: string, days: string) => {
+ formHelper.setValue('lock_enabled', enabled);
+ formHelper.setValue('lock_mode', mode);
+ formHelper.setValue('lock_retention_period_days', days);
+ ['lock_enabled', 'lock_mode', 'lock_retention_period_days'].forEach((name) => {
+ const control = component.bucketForm.get(name);
+ expect(control.valid).toBeTruthy();
+ expect(control.errors).toBeNull();
+ });
+ };
+
+ it('should check lock enabled checkbox [mode=create]', () => {
+ component.createForm();
+ const control = component.bucketForm.get('lock_enabled');
+ expect(control.disabled).toBeFalsy();
+ });
+
+ it('should check lock enabled checkbox [mode=edit]', () => {
+ component.editing = true;
+ component.createForm();
+ const control = component.bucketForm.get('lock_enabled');
+ expect(control.disabled).toBeTruthy();
+ });
+
+ it('should have the "lockDays" error', () => {
+ formHelper.setValue('lock_enabled', true);
+ const control = component.bucketForm.get('lock_retention_period_days');
+ control.updateValueAndValidity();
+ expect(control.value).toBe(0);
+ expect(control.invalid).toBeTruthy();
+ formHelper.expectError(control, 'lockDays');
+ });
+
+ it('should have the "pattern" error [1]', () => {
+ expectPatternLockError('-1');
+ });
+
+ it('should have the "pattern" error [2]', () => {
+ expectPatternLockError('1.2');
+ });
+
+ it('should have valid values [1]', () => {
+ expectValidLockInputs(true, 'Governance', '1');
+ });
+
+ it('should have valid values [2]', () => {
+ expectValidLockInputs(false, 'Compliance', '2');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
new file mode 100644
index 000000000..1d5aede39
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
@@ -0,0 +1,264 @@
+import { Component, OnInit } from '@angular/core';
+import { Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import { forkJoin } from 'rxjs';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { RgwSiteService } from '~/app/shared/api/rgw-site.service';
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
+import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
+
+@Component({
+ selector: 'cd-rgw-bucket-form',
+ templateUrl: './rgw-bucket-form.component.html',
+ styleUrls: ['./rgw-bucket-form.component.scss']
+})
+export class RgwBucketFormComponent extends CdForm implements OnInit {
+ bucketForm: CdFormGroup;
+ editing = false;
+ owners: string[] = null;
+ action: string;
+ resource: string;
+ zonegroup: string;
+ placementTargets: object[] = [];
+ isVersioningAlreadyEnabled = false;
+ isMfaDeleteAlreadyEnabled = false;
+ icons = Icons;
+
+ get isVersioningEnabled(): boolean {
+ return this.bucketForm.getValue('versioning');
+ }
+ get isMfaDeleteEnabled(): boolean {
+ return this.bucketForm.getValue('mfa-delete');
+ }
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private formBuilder: CdFormBuilder,
+ private rgwBucketService: RgwBucketService,
+ private rgwSiteService: RgwSiteService,
+ private rgwUserService: RgwUserService,
+ private notificationService: NotificationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.editing = this.router.url.startsWith(`/rgw/bucket/${URLVerbs.EDIT}`);
+ this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
+ this.resource = $localize`bucket`;
+ this.createForm();
+ }
+
+ createForm() {
+ const self = this;
+ const lockDaysValidator = CdValidators.custom('lockDays', () => {
+ if (!self.bucketForm || !_.get(self.bucketForm.getRawValue(), 'lock_enabled')) {
+ return false;
+ }
+ const lockDays = Number(self.bucketForm.getValue('lock_retention_period_days'));
+ return !Number.isInteger(lockDays) || lockDays === 0;
+ });
+ this.bucketForm = this.formBuilder.group({
+ id: [null],
+ bid: [
+ null,
+ [Validators.required],
+ this.editing
+ ? []
+ : [CdValidators.bucketName(), CdValidators.bucketExistence(false, this.rgwBucketService)]
+ ],
+ owner: [null, [Validators.required]],
+ 'placement-target': [null, this.editing ? [] : [Validators.required]],
+ versioning: [null],
+ 'mfa-delete': [null],
+ 'mfa-token-serial': [''],
+ 'mfa-token-pin': [''],
+ lock_enabled: [{ value: false, disabled: this.editing }],
+ lock_mode: ['COMPLIANCE'],
+ lock_retention_period_days: [0, [CdValidators.number(false), lockDaysValidator]]
+ });
+ }
+
+ ngOnInit() {
+ const promises = {
+ owners: this.rgwUserService.enumerate()
+ };
+
+ if (!this.editing) {
+ promises['getPlacementTargets'] = this.rgwSiteService.get('placement-targets');
+ }
+
+ // Process route parameters.
+ this.route.params.subscribe((params: { bid: string }) => {
+ if (params.hasOwnProperty('bid')) {
+ const bid = decodeURIComponent(params.bid);
+ promises['getBid'] = this.rgwBucketService.get(bid);
+ }
+
+ forkJoin(promises).subscribe((data: any) => {
+ // Get the list of possible owners.
+ this.owners = (<string[]>data.owners).sort();
+
+ // Get placement targets:
+ if (data['getPlacementTargets']) {
+ const placementTargets = data['getPlacementTargets'];
+ this.zonegroup = placementTargets['zonegroup'];
+ _.forEach(placementTargets['placement_targets'], (placementTarget) => {
+ placementTarget['description'] = `${placementTarget['name']} (${$localize`pool`}: ${
+ placementTarget['data_pool']
+ })`;
+ this.placementTargets.push(placementTarget);
+ });
+
+ // If there is only 1 placement target, select it by default:
+ if (this.placementTargets.length === 1) {
+ this.bucketForm.get('placement-target').setValue(this.placementTargets[0]['name']);
+ }
+ }
+
+ if (data['getBid']) {
+ const bidResp = data['getBid'];
+ // Get the default values (incl. the values from disabled fields).
+ const defaults = _.clone(this.bucketForm.getRawValue());
+
+ // Get the values displayed in the form. We need to do that to
+ // extract those key/value pairs from the response data, otherwise
+ // the Angular react framework will throw an error if there is no
+ // field for a given key.
+ let value: object = _.pick(bidResp, _.keys(defaults));
+ value['lock_retention_period_days'] = this.rgwBucketService.getLockDays(bidResp);
+ value['placement-target'] = bidResp['placement_rule'];
+ value['versioning'] = bidResp['versioning'] === RgwBucketVersioning.ENABLED;
+ value['mfa-delete'] = bidResp['mfa_delete'] === RgwBucketMfaDelete.ENABLED;
+
+ // Append default values.
+ value = _.merge(defaults, value);
+
+ // Update the form.
+ this.bucketForm.setValue(value);
+ if (this.editing) {
+ this.isVersioningAlreadyEnabled = this.isVersioningEnabled;
+ this.isMfaDeleteAlreadyEnabled = this.isMfaDeleteEnabled;
+ this.setMfaDeleteValidators();
+ if (value['lock_enabled']) {
+ this.bucketForm.controls['versioning'].disable();
+ }
+ }
+ }
+
+ this.loadingReady();
+ });
+ });
+ }
+
+ goToListView() {
+ this.router.navigate(['/rgw/bucket']);
+ }
+
+ submit() {
+ // Exit immediately if the form isn't dirty.
+ if (this.bucketForm.pristine) {
+ this.goToListView();
+ return;
+ }
+ const values = this.bucketForm.value;
+ if (this.editing) {
+ // Edit
+ const versioning = this.getVersioningStatus();
+ const mfaDelete = this.getMfaDeleteStatus();
+ this.rgwBucketService
+ .update(
+ values['bid'],
+ values['id'],
+ values['owner'],
+ versioning,
+ mfaDelete,
+ values['mfa-token-serial'],
+ values['mfa-token-pin'],
+ values['lock_mode'],
+ values['lock_retention_period_days']
+ )
+ .subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated Object Gateway bucket '${values.bid}'.`
+ );
+ this.goToListView();
+ },
+ () => {
+ // Reset the 'Submit' button.
+ this.bucketForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ } else {
+ // Add
+ this.rgwBucketService
+ .create(
+ values['bid'],
+ values['owner'],
+ this.zonegroup,
+ values['placement-target'],
+ values['lock_enabled'],
+ values['lock_mode'],
+ values['lock_retention_period_days']
+ )
+ .subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created Object Gateway bucket '${values.bid}'`
+ );
+ this.goToListView();
+ },
+ () => {
+ // Reset the 'Submit' button.
+ this.bucketForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+ }
+
+ areMfaCredentialsRequired() {
+ return (
+ this.isMfaDeleteEnabled !== this.isMfaDeleteAlreadyEnabled ||
+ (this.isMfaDeleteAlreadyEnabled &&
+ this.isVersioningEnabled !== this.isVersioningAlreadyEnabled)
+ );
+ }
+
+ setMfaDeleteValidators() {
+ const mfaTokenSerialControl = this.bucketForm.get('mfa-token-serial');
+ const mfaTokenPinControl = this.bucketForm.get('mfa-token-pin');
+
+ if (this.areMfaCredentialsRequired()) {
+ mfaTokenSerialControl.setValidators(Validators.required);
+ mfaTokenPinControl.setValidators(Validators.required);
+ } else {
+ mfaTokenSerialControl.setValidators(null);
+ mfaTokenPinControl.setValidators(null);
+ }
+
+ mfaTokenSerialControl.updateValueAndValidity();
+ mfaTokenPinControl.updateValueAndValidity();
+ }
+
+ getVersioningStatus() {
+ return this.isVersioningEnabled ? RgwBucketVersioning.ENABLED : RgwBucketVersioning.SUSPENDED;
+ }
+
+ getMfaDeleteStatus() {
+ return this.isMfaDeleteEnabled ? RgwBucketMfaDelete.ENABLED : RgwBucketMfaDelete.DISABLED;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html
new file mode 100644
index 000000000..b5e75841a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html
@@ -0,0 +1,44 @@
+<cd-table #table
+ [autoReload]="false"
+ [data]="buckets"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="multiClick"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)"
+ identifier="bid"
+ (fetchData)="getBucketList($event)"
+ [status]="tableStatus">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-rgw-bucket-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-rgw-bucket-details>
+</cd-table>
+
+<ng-template #bucketSizeTpl
+ let-row="row">
+ <cd-usage-bar *ngIf="row.bucket_quota.max_size > 0 && row.bucket_quota.enabled; else noSizeQuota"
+ [total]="row.bucket_quota.max_size"
+ [used]="row.bucket_size">
+ </cd-usage-bar>
+
+ <ng-template #noSizeQuota
+ i18n>No Limit</ng-template>
+</ng-template>
+
+<ng-template #bucketObjectTpl
+ let-row="row">
+ <cd-usage-bar *ngIf="row.bucket_quota.max_objects > 0 && row.bucket_quota.enabled; else noObjectQuota"
+ [total]="row.bucket_quota.max_objects"
+ [used]="row.num_objects"
+ [isBinary]="false">
+ </cd-usage-bar>
+
+ <ng-template #noObjectQuota
+ i18n>No Limit</ng-template>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts
new file mode 100644
index 000000000..ff0705793
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts
@@ -0,0 +1,178 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { of } from 'rxjs';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { RgwBucketDetailsComponent } from '../rgw-bucket-details/rgw-bucket-details.component';
+import { RgwBucketListComponent } from './rgw-bucket-list.component';
+
+describe('RgwBucketListComponent', () => {
+ let component: RgwBucketListComponent;
+ let fixture: ComponentFixture<RgwBucketListComponent>;
+ let rgwBucketService: RgwBucketService;
+ let rgwBucketServiceListSpy: jasmine.Spy;
+
+ configureTestBed({
+ declarations: [RgwBucketListComponent, RgwBucketDetailsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ RouterTestingModule,
+ SharedModule,
+ NgbNavModule,
+ HttpClientTestingModule
+ ]
+ });
+
+ beforeEach(() => {
+ rgwBucketService = TestBed.inject(RgwBucketService);
+ rgwBucketServiceListSpy = spyOn(rgwBucketService, 'list');
+ rgwBucketServiceListSpy.and.returnValue(of([]));
+ fixture = TestBed.createComponent(RgwBucketListComponent);
+ component = fixture.componentInstance;
+ spyOn(component, 'setTableRefreshTimeout').and.stub();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(rgwBucketServiceListSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Edit', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ it('should test if bucket data is tranformed correctly', () => {
+ rgwBucketServiceListSpy.and.returnValue(
+ of([
+ {
+ bucket: 'bucket',
+ owner: 'testid',
+ usage: {
+ 'rgw.main': {
+ size_actual: 4,
+ num_objects: 2
+ },
+ 'rgw.none': {
+ size_actual: 6,
+ num_objects: 6
+ }
+ },
+ bucket_quota: {
+ max_size: 20,
+ max_objects: 10,
+ enabled: true
+ }
+ }
+ ])
+ );
+ component.getBucketList(null);
+ expect(rgwBucketServiceListSpy).toHaveBeenCalledTimes(2);
+ expect(component.buckets).toEqual([
+ {
+ bucket: 'bucket',
+ owner: 'testid',
+ usage: {
+ 'rgw.main': { size_actual: 4, num_objects: 2 },
+ 'rgw.none': { size_actual: 6, num_objects: 6 }
+ },
+ bucket_quota: {
+ max_size: 20,
+ max_objects: 10,
+ enabled: true
+ },
+ bucket_size: 4,
+ num_objects: 2,
+ size_usage: 0.2,
+ object_usage: 0.2
+ }
+ ]);
+ });
+
+ it('should usage bars only if quota enabled', () => {
+ rgwBucketServiceListSpy.and.returnValue(
+ of([
+ {
+ bucket: 'bucket',
+ owner: 'testid',
+ bucket_quota: {
+ max_size: 1024,
+ max_objects: 10,
+ enabled: true
+ }
+ }
+ ])
+ );
+ component.getBucketList(null);
+ expect(rgwBucketServiceListSpy).toHaveBeenCalledTimes(2);
+ fixture.detectChanges();
+ const usageBars = fixture.debugElement.nativeElement.querySelectorAll('cd-usage-bar');
+ expect(usageBars.length).toBe(2);
+ });
+
+ it('should not show any usage bars if quota disabled', () => {
+ rgwBucketServiceListSpy.and.returnValue(
+ of([
+ {
+ bucket: 'bucket',
+ owner: 'testid',
+ bucket_quota: {
+ max_size: 1024,
+ max_objects: 10,
+ enabled: false
+ }
+ }
+ ])
+ );
+ component.getBucketList(null);
+ expect(rgwBucketServiceListSpy).toHaveBeenCalledTimes(2);
+ fixture.detectChanges();
+ const usageBars = fixture.debugElement.nativeElement.querySelectorAll('cd-usage-bar');
+ expect(usageBars.length).toBe(0);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
new file mode 100644
index 000000000..479da864a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
@@ -0,0 +1,188 @@
+import { Component, NgZone, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import _ from 'lodash';
+import { forkJoin as observableForkJoin, Observable, Subscriber } from 'rxjs';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+
+const BASE_URL = 'rgw/bucket';
+
+@Component({
+ selector: 'cd-rgw-bucket-list',
+ templateUrl: './rgw-bucket-list.component.html',
+ styleUrls: ['./rgw-bucket-list.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class RgwBucketListComponent extends ListWithDetails implements OnInit {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+ @ViewChild('bucketSizeTpl', { static: true })
+ bucketSizeTpl: TemplateRef<any>;
+ @ViewChild('bucketObjectTpl', { static: true })
+ bucketObjectTpl: TemplateRef<any>;
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ columns: CdTableColumn[] = [];
+ buckets: object[] = [];
+ selection: CdTableSelection = new CdTableSelection();
+ staleTimeout: number;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private dimlessPipe: DimlessPipe,
+ private rgwBucketService: RgwBucketService,
+ private modalService: ModalService,
+ private urlBuilder: URLBuilderService,
+ public actionLabels: ActionLabelsI18n,
+ protected ngZone: NgZone
+ ) {
+ super(ngZone);
+ }
+
+ ngOnInit() {
+ this.permission = this.authStorageService.getPermissions().rgw;
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'bid',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Owner`,
+ prop: 'owner',
+ flexGrow: 2.5
+ },
+ {
+ name: $localize`Used Capacity`,
+ prop: 'bucket_size',
+ flexGrow: 0.6,
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`Capacity Limit %`,
+ prop: 'size_usage',
+ cellTemplate: this.bucketSizeTpl,
+ flexGrow: 0.8
+ },
+ {
+ name: $localize`Objects`,
+ prop: 'num_objects',
+ flexGrow: 0.6,
+ pipe: this.dimlessPipe
+ },
+ {
+ name: $localize`Object Limit %`,
+ prop: 'object_usage',
+ cellTemplate: this.bucketObjectTpl,
+ flexGrow: 0.8
+ }
+ ];
+ const getBucketUri = () =>
+ this.selection.first() && `${encodeURIComponent(this.selection.first().bid)}`;
+ const addAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ name: this.actionLabels.CREATE,
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ };
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () => this.urlBuilder.getEdit(getBucketUri()),
+ name: this.actionLabels.EDIT
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteAction(),
+ disable: () => !this.selection.hasSelection,
+ name: this.actionLabels.DELETE,
+ canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ };
+ this.tableActions = [addAction, editAction, deleteAction];
+ this.setTableRefreshTimeout();
+ }
+
+ transformBucketData() {
+ _.forEach(this.buckets, (bucketKey) => {
+ const maxBucketSize = bucketKey['bucket_quota']['max_size'];
+ const maxBucketObjects = bucketKey['bucket_quota']['max_objects'];
+ bucketKey['bucket_size'] = 0;
+ bucketKey['num_objects'] = 0;
+ if (!_.isEmpty(bucketKey['usage'])) {
+ bucketKey['bucket_size'] = bucketKey['usage']['rgw.main']['size_actual'];
+ bucketKey['num_objects'] = bucketKey['usage']['rgw.main']['num_objects'];
+ }
+ bucketKey['size_usage'] =
+ maxBucketSize > 0 ? bucketKey['bucket_size'] / maxBucketSize : undefined;
+ bucketKey['object_usage'] =
+ maxBucketObjects > 0 ? bucketKey['num_objects'] / maxBucketObjects : undefined;
+ });
+ }
+
+ getBucketList(context: CdTableFetchDataContext) {
+ this.setTableRefreshTimeout();
+ this.rgwBucketService.list(true).subscribe(
+ (resp: object[]) => {
+ this.buckets = resp;
+ this.transformBucketData();
+ },
+ () => {
+ context.error();
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteAction() {
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: this.selection.hasSingleSelection ? $localize`bucket` : $localize`buckets`,
+ itemNames: this.selection.selected.map((bucket: any) => bucket['bid']),
+ submitActionObservable: () => {
+ return new Observable((observer: Subscriber<any>) => {
+ // Delete all selected data table rows.
+ observableForkJoin(
+ this.selection.selected.map((bucket: any) => {
+ return this.rgwBucketService.delete(bucket.bid);
+ })
+ ).subscribe({
+ error: (error) => {
+ // Forward the error to the observer.
+ observer.error(error);
+ // Reload the data table content because some deletions might
+ // have been executed successfully in the meanwhile.
+ this.table.refreshBtn();
+ },
+ complete: () => {
+ // Notify the observer that we are done.
+ observer.complete();
+ // Reload the data table content.
+ this.table.refreshBtn();
+ }
+ });
+ });
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html
new file mode 100644
index 000000000..e53eaa8f7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html
@@ -0,0 +1,38 @@
+<ng-container *ngIf="selection">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="rgw-daemon-details">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value [data]="metadata"
+ (fetchData)="getMetaData()">
+ </cd-table-key-value>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-counters">
+ <a ngbNavLink
+ i18n>Performance Counters</a>
+ <ng-template ngbNavContent>
+ <cd-table-performance-counter serviceType="rgw"
+ [serviceId]="serviceMapId">
+ </cd-table-performance-counter>
+ </ng-template>
+ </li>
+ <li ngbNavItem="performance-details"
+ *ngIf="grafanaPermission.read">
+ <a ngbNavLink
+ i18n>Performance Details</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'rgw-instance-detail?var-rgw_servers=rgw.' + this.serviceId"
+ uid="x5ARzZtmk"
+ grafanaStyle="one">
+ </cd-grafana>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts
new file mode 100644
index 000000000..40ea5a043
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { PerformanceCounterModule } from '~/app/ceph/performance-counter/performance-counter.module';
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwDaemonDetailsComponent } from './rgw-daemon-details.component';
+
+describe('RgwDaemonDetailsComponent', () => {
+ let component: RgwDaemonDetailsComponent;
+ let fixture: ComponentFixture<RgwDaemonDetailsComponent>;
+
+ configureTestBed({
+ declarations: [RgwDaemonDetailsComponent],
+ imports: [SharedModule, PerformanceCounterModule, HttpClientTestingModule, NgbNavModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwDaemonDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = undefined;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should set service id and service map id on changes', () => {
+ const daemon = new RgwDaemon();
+ daemon.id = 'daemon1';
+ daemon.service_map_id = '4832';
+ component.selection = daemon;
+ component.ngOnChanges();
+
+ expect(component.serviceId).toBe(daemon.id);
+ expect(component.serviceMapId).toBe(daemon.service_map_id);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts
new file mode 100644
index 000000000..38a309e0a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts
@@ -0,0 +1,46 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import _ from 'lodash';
+
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-rgw-daemon-details',
+ templateUrl: './rgw-daemon-details.component.html',
+ styleUrls: ['./rgw-daemon-details.component.scss']
+})
+export class RgwDaemonDetailsComponent implements OnChanges {
+ metadata: any;
+ serviceId = '';
+ serviceMapId = '';
+ grafanaPermission: Permission;
+
+ @Input()
+ selection: RgwDaemon;
+
+ constructor(
+ private rgwDaemonService: RgwDaemonService,
+ private authStorageService: AuthStorageService
+ ) {
+ this.grafanaPermission = this.authStorageService.getPermissions().grafana;
+ }
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.serviceId = this.selection.id;
+ this.serviceMapId = this.selection.service_map_id;
+ }
+ }
+
+ getMetaData() {
+ if (_.isEmpty(this.serviceId)) {
+ return;
+ }
+ this.rgwDaemonService.get(this.serviceId).subscribe((resp: any) => {
+ this.metadata = resp['rgw_metadata'];
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html
new file mode 100644
index 000000000..38683a6f6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html
@@ -0,0 +1,46 @@
+<ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem>
+ <a ngbNavLink
+ i18n>Daemons List</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="daemons"
+ [columns]="columns"
+ columnMode="flex"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (fetchData)="getDaemonList($event)">
+ <cd-rgw-daemon-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-rgw-daemon-details>
+ </cd-table>
+ </ng-template>
+ </li>
+
+ <li ngbNavItem
+ *ngIf="grafanaPermission.read">
+ <a ngbNavLink
+ i18n>Overall Performance</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'rgw-overview?'"
+ uid="WAkugZpiz"
+ grafanaStyle="two">
+ </cd-grafana>
+ </ng-template>
+ </li>
+
+ <li ngbNavItem
+ *ngIf="grafanaPermission.read && isMultiSite">
+ <a ngbNavLink
+ i18n>Sync Performance</a>
+ <ng-template ngbNavContent>
+ <cd-grafana [grafanaPath]="'radosgw-sync-overview?'"
+ uid="rgw-sync-overview"
+ grafanaStyle="two">
+ </cd-grafana>
+ </ng-template>
+ </li>
+</ul>
+
+<div [ngbNavOutlet]="nav"></div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts
new file mode 100644
index 000000000..ecf0bedf9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts
@@ -0,0 +1,106 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { of } from 'rxjs';
+
+import { PerformanceCounterModule } from '~/app/ceph/performance-counter/performance-counter.module';
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { RgwSiteService } from '~/app/shared/api/rgw-site.service';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, TabHelper } from '~/testing/unit-test-helper';
+import { RgwDaemonDetailsComponent } from '../rgw-daemon-details/rgw-daemon-details.component';
+import { RgwDaemonListComponent } from './rgw-daemon-list.component';
+
+describe('RgwDaemonListComponent', () => {
+ let component: RgwDaemonListComponent;
+ let fixture: ComponentFixture<RgwDaemonListComponent>;
+ let getPermissionsSpy: jasmine.Spy;
+ let getRealmsSpy: jasmine.Spy;
+ let listDaemonsSpy: jest.SpyInstance;
+ const permissions = new Permissions({ grafana: ['read'] });
+ const daemon: RgwDaemon = {
+ id: '8000',
+ service_map_id: '4803',
+ version: 'ceph version',
+ server_hostname: 'ceph',
+ realm_name: 'realm1',
+ zonegroup_name: 'zg1-realm1',
+ zone_name: 'zone1-zg1-realm1',
+ default: true
+ };
+
+ const expectTabsAndHeading = (length: number, heading: string) => {
+ const tabs = TabHelper.getTextContents(fixture);
+ expect(tabs.length).toEqual(length);
+ expect(tabs[length - 1]).toEqual(heading);
+ };
+
+ configureTestBed({
+ declarations: [RgwDaemonListComponent, RgwDaemonDetailsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ NgbNavModule,
+ PerformanceCounterModule,
+ SharedModule,
+ RouterTestingModule
+ ]
+ });
+
+ beforeEach(() => {
+ getPermissionsSpy = spyOn(TestBed.inject(AuthStorageService), 'getPermissions');
+ getPermissionsSpy.and.returnValue(new Permissions({}));
+ getRealmsSpy = spyOn(TestBed.inject(RgwSiteService), 'get');
+ getRealmsSpy.and.returnValue(of([]));
+ listDaemonsSpy = jest
+ .spyOn(TestBed.inject(RgwDaemonService), 'list')
+ .mockReturnValue(of([daemon]));
+ fixture = TestBed.createComponent(RgwDaemonListComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should show a row with daemon info', fakeAsync(() => {
+ fixture.detectChanges();
+ tick();
+ expect(listDaemonsSpy).toHaveBeenCalledTimes(1);
+ expect(component.daemons).toEqual([daemon]);
+ expect(fixture.debugElement.query(By.css('cd-table')).nativeElement.textContent).toContain(
+ 'total 1'
+ );
+
+ fixture.destroy();
+ }));
+
+ it('should only show Daemons List tab', () => {
+ fixture.detectChanges();
+
+ expectTabsAndHeading(1, 'Daemons List');
+ });
+
+ it('should show Overall Performance tab', () => {
+ getPermissionsSpy.and.returnValue(permissions);
+ fixture.detectChanges();
+
+ expectTabsAndHeading(2, 'Overall Performance');
+ });
+
+ it('should show Sync Performance tab', () => {
+ getPermissionsSpy.and.returnValue(permissions);
+ getRealmsSpy.and.returnValue(of(['realm1']));
+ fixture.detectChanges();
+
+ expectTabsAndHeading(3, 'Sync Performance');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts
new file mode 100644
index 000000000..c620843fb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts
@@ -0,0 +1,82 @@
+import { Component, OnInit } from '@angular/core';
+
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { RgwSiteService } from '~/app/shared/api/rgw-site.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { Permission } from '~/app/shared/models/permissions';
+import { CephShortVersionPipe } from '~/app/shared/pipes/ceph-short-version.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-rgw-daemon-list',
+ templateUrl: './rgw-daemon-list.component.html',
+ styleUrls: ['./rgw-daemon-list.component.scss']
+})
+export class RgwDaemonListComponent extends ListWithDetails implements OnInit {
+ columns: CdTableColumn[] = [];
+ daemons: RgwDaemon[] = [];
+ grafanaPermission: Permission;
+ isMultiSite: boolean;
+
+ constructor(
+ private rgwDaemonService: RgwDaemonService,
+ private authStorageService: AuthStorageService,
+ private cephShortVersionPipe: CephShortVersionPipe,
+ private rgwSiteService: RgwSiteService
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ this.grafanaPermission = this.authStorageService.getPermissions().grafana;
+ this.columns = [
+ {
+ name: $localize`ID`,
+ prop: 'id',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Hostname`,
+ prop: 'server_hostname',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Zone`,
+ prop: 'zone_name',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Zone Group`,
+ prop: 'zonegroup_name',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Realm`,
+ prop: 'realm_name',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Version`,
+ prop: 'version',
+ flexGrow: 1,
+ pipe: this.cephShortVersionPipe
+ }
+ ];
+ this.rgwSiteService
+ .get('realms')
+ .subscribe((realms: string[]) => (this.isMultiSite = realms.length > 0));
+ }
+
+ getDaemonList(context: CdTableFetchDataContext) {
+ this.rgwDaemonService.list().subscribe(this.updateDaemons, () => {
+ context.error();
+ });
+ }
+
+ private updateDaemons = (daemons: RgwDaemon[]) => {
+ this.daemons = daemons;
+ };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html
new file mode 100644
index 000000000..8276e01e7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html
@@ -0,0 +1,70 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+ <!-- Type -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !editing}"
+ for="type"
+ i18n>Type</label>
+ <div class="cd-col-form-input">
+ <input id="type"
+ class="form-control"
+ type="text"
+ *ngIf="editing"
+ [readonly]="true"
+ formControlName="type">
+ <select id="type"
+ class="form-control"
+ formControlName="type"
+ *ngIf="!editing"
+ autofocus>
+ <option i18n
+ *ngIf="types !== null"
+ [ngValue]="null">-- Select a type --</option>
+ <option *ngFor="let type of types"
+ [value]="type">{{ type }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('type', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Permission -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="perm"
+ i18n>Permission</label>
+ <div class="cd-col-form-input">
+ <select id="perm"
+ class="form-control"
+ formControlName="perm">
+ <option i18n
+ [ngValue]="null">-- Select a permission --</option>
+ <option *ngFor="let perm of ['read', 'write', '*']"
+ [value]="perm">
+ {{ perm }}
+ </option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('perm', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="formGroup"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts
new file mode 100644
index 000000000..e270fb254
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwUserCapabilityModalComponent } from './rgw-user-capability-modal.component';
+
+describe('RgwUserCapabilityModalComponent', () => {
+ let component: RgwUserCapabilityModalComponent;
+ let fixture: ComponentFixture<RgwUserCapabilityModalComponent>;
+
+ configureTestBed({
+ declarations: [RgwUserCapabilityModalComponent],
+ imports: [ReactiveFormsModule, SharedModule, RouterTestingModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwUserCapabilityModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts
new file mode 100644
index 000000000..3a3c9ac46
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts
@@ -0,0 +1,92 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { RgwUserCapabilities } from '../models/rgw-user-capabilities';
+import { RgwUserCapability } from '../models/rgw-user-capability';
+
+@Component({
+ selector: 'cd-rgw-user-capability-modal',
+ templateUrl: './rgw-user-capability-modal.component.html',
+ styleUrls: ['./rgw-user-capability-modal.component.scss']
+})
+export class RgwUserCapabilityModalComponent {
+ /**
+ * The event that is triggered when the 'Add' or 'Update' button
+ * has been pressed.
+ */
+ @Output()
+ submitAction = new EventEmitter();
+
+ formGroup: CdFormGroup;
+ editing = true;
+ types: string[] = [];
+ resource: string;
+ action: string;
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.resource = $localize`capability`;
+ this.createForm();
+ }
+
+ createForm() {
+ this.formGroup = this.formBuilder.group({
+ type: [null, [Validators.required]],
+ perm: [null, [Validators.required]]
+ });
+ }
+
+ /**
+ * Set the 'editing' flag. If set to TRUE, the modal dialog is in 'Edit' mode,
+ * otherwise in 'Add' mode. According to the mode the dialog and its controls
+ * behave different.
+ * @param {boolean} viewing
+ */
+ setEditing(editing: boolean = true) {
+ this.editing = editing;
+ this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.ADD;
+ }
+
+ /**
+ * Set the values displayed in the dialog.
+ */
+ setValues(type: string, perm: string) {
+ this.formGroup.setValue({
+ type: type,
+ perm: perm
+ });
+ }
+
+ /**
+ * Set the current capabilities of the user.
+ */
+ setCapabilities(capabilities: RgwUserCapability[]) {
+ // Parse the configured capabilities to get a list of types that
+ // should be displayed.
+ const usedTypes: string[] = [];
+ capabilities.forEach((capability) => {
+ usedTypes.push(capability.type);
+ });
+ this.types = [];
+ RgwUserCapabilities.getAll().forEach((type) => {
+ if (_.indexOf(usedTypes, type) === -1) {
+ this.types.push(type);
+ }
+ });
+ }
+
+ onSubmit() {
+ const capability: RgwUserCapability = this.formGroup.value;
+ this.submitAction.emit(capability);
+ this.activeModal.close();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html
new file mode 100644
index 000000000..2283f4c54
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html
@@ -0,0 +1,178 @@
+<ng-container *ngIf="selection">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="rgw-user-details">
+ <li ngbNavItem="details">
+ <a ngbNavLink
+ i18n>Details</a>
+ <ng-template ngbNavContent>
+ <div *ngIf="user">
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Tenant</td>
+ <td class="w-75">{{ user.tenant }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold w-25">User ID</td>
+ <td class="w-75">{{ user.user_id }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold w-25">Username</td>
+ <td class="w-75">{{ user.uid }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Full name</td>
+ <td>{{ user.display_name }}</td>
+ </tr>
+ <tr *ngIf="user.email?.length">
+ <td i18n
+ class="bold">Email address</td>
+ <td>{{ user.email }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Suspended</td>
+ <td>{{ user.suspended | booleanText }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">System</td>
+ <td>{{ user.system === 'true' | booleanText }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum buckets</td>
+ <td>{{ user.max_buckets | map:maxBucketsMap }}</td>
+ </tr>
+ <tr *ngIf="user.subusers && user.subusers.length">
+ <td i18n
+ class="bold">Subusers</td>
+ <td>
+ <div *ngFor="let subuser of user.subusers">
+ {{ subuser.id }} ({{ subuser.permissions }})
+ </div>
+ </td>
+ </tr>
+ <tr *ngIf="user.caps && user.caps.length">
+ <td i18n
+ class="bold">Capabilities</td>
+ <td>
+ <div *ngFor="let cap of user.caps">
+ {{ cap.type }} ({{ cap.perm }})
+ </div>
+ </td>
+ </tr>
+ <tr *ngIf="user.mfa_ids?.length">
+ <td i18n
+ class="bold">MFAs(Id)</td>
+ <td>{{ user.mfa_ids | join}}</td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- User quota -->
+ <div *ngIf="user.user_quota">
+ <legend i18n>User quota</legend>
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Enabled</td>
+ <td class="w-75">{{ user.user_quota.enabled | booleanText }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum size</td>
+ <td *ngIf="!user.user_quota.enabled">-</td>
+ <td *ngIf="user.user_quota.enabled && user.user_quota.max_size <= -1"
+ i18n>Unlimited</td>
+ <td *ngIf="user.user_quota.enabled && user.user_quota.max_size > -1">
+ {{ user.user_quota.max_size | dimlessBinary }}
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum objects</td>
+ <td *ngIf="!user.user_quota.enabled">-</td>
+ <td *ngIf="user.user_quota.enabled && user.user_quota.max_objects <= -1"
+ i18n>Unlimited</td>
+ <td *ngIf="user.user_quota.enabled && user.user_quota.max_objects > -1">
+ {{ user.user_quota.max_objects }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <!-- Bucket quota -->
+ <div *ngIf="user.bucket_quota">
+ <legend i18n>Bucket quota</legend>
+ <table class="table table-striped table-bordered">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Enabled</td>
+ <td class="w-75">{{ user.bucket_quota.enabled | booleanText }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum size</td>
+ <td *ngIf="!user.bucket_quota.enabled">-</td>
+ <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_size <= -1"
+ i18n>Unlimited</td>
+ <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_size > -1">
+ {{ user.bucket_quota.max_size | dimlessBinary }}
+ </td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold">Maximum objects</td>
+ <td *ngIf="!user.bucket_quota.enabled">-</td>
+ <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_objects <= -1"
+ i18n>Unlimited</td>
+ <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_objects > -1">
+ {{ user.bucket_quota.max_objects }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </ng-template>
+ </li>
+ <li ngbNavItem="keys"
+ *ngIf="keys.length">
+ <a ngbNavLink
+ i18n>Keys</a>
+ <ng-template ngbNavContent>
+ <cd-table [data]="keys"
+ [columns]="keysColumns"
+ columnMode="flex"
+ selectionType="multi"
+ forceIdentifier="true"
+ (updateSelection)="updateKeysSelection($event)">
+ <div class="table-actions">
+ <div class="btn-group"
+ dropdown>
+ <button type="button"
+ class="btn btn-accent"
+ [disabled]="!keysSelection.hasSingleSelection"
+ (click)="showKeyModal()">
+ <i [ngClass]="[icons.show]"></i>
+ <ng-container i18n>Show</ng-container>
+ </button>
+ </div>
+ </div>
+ </cd-table>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts
new file mode 100644
index 000000000..62519cdf4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts
@@ -0,0 +1,94 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, TabHelper } from '~/testing/unit-test-helper';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+import { RgwUserDetailsComponent } from './rgw-user-details.component';
+
+describe('RgwUserDetailsComponent', () => {
+ let component: RgwUserDetailsComponent;
+ let fixture: ComponentFixture<RgwUserDetailsComponent>;
+
+ configureTestBed({
+ declarations: [RgwUserDetailsComponent],
+ imports: [BrowserAnimationsModule, HttpClientTestingModule, SharedModule, NgbNavModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwUserDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = {};
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+
+ const tabs = TabHelper.getTextContents(fixture);
+ expect(tabs).toContain('Details');
+ expect(tabs).not.toContain('Keys');
+ });
+
+ it('should show "Details" tab', () => {
+ component.selection = { uid: 'myUsername' };
+ fixture.detectChanges();
+
+ const tabs = TabHelper.getTextContents(fixture);
+ expect(tabs).toContain('Details');
+ expect(tabs).not.toContain('Keys');
+ });
+
+ it('should show "Keys" tab', () => {
+ const s3Key = new RgwUserS3Key();
+ component.selection = { keys: [s3Key] };
+ component.ngOnChanges();
+ fixture.detectChanges();
+
+ const tabs = TabHelper.getTextContents(fixture);
+ expect(tabs).toContain('Details');
+ expect(tabs).toContain('Keys');
+ });
+
+ it('should show correct "System" info', () => {
+ component.selection = { uid: '', email: '', system: 'true', keys: [], swift_keys: [] };
+
+ component.ngOnChanges();
+ fixture.detectChanges();
+
+ const detailsTab = fixture.debugElement.nativeElement.querySelectorAll(
+ '.table.table-striped.table-bordered tr td'
+ );
+ expect(detailsTab[10].textContent).toEqual('System');
+ expect(detailsTab[11].textContent).toEqual('Yes');
+
+ component.selection.system = 'false';
+ component.ngOnChanges();
+ fixture.detectChanges();
+
+ expect(detailsTab[11].textContent).toEqual('No');
+ });
+
+ it('should show mfa ids only if length > 0', () => {
+ component.selection = {
+ uid: 'dashboard',
+ email: '',
+ system: 'true',
+ keys: [],
+ swift_keys: [],
+ mfa_ids: ['testMFA1', 'testMFA2']
+ };
+
+ component.ngOnChanges();
+ fixture.detectChanges();
+
+ const detailsTab = fixture.debugElement.nativeElement.querySelectorAll(
+ '.table.table-striped.table-bordered tr td'
+ );
+ expect(detailsTab[14].textContent).toEqual('MFAs(Id)');
+ expect(detailsTab[15].textContent).toEqual('testMFA1, testMFA2');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts
new file mode 100644
index 000000000..2c4a92612
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts
@@ -0,0 +1,120 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import _ from 'lodash';
+
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+import { RgwUserSwiftKey } from '../models/rgw-user-swift-key';
+import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
+import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
+
+@Component({
+ selector: 'cd-rgw-user-details',
+ templateUrl: './rgw-user-details.component.html',
+ styleUrls: ['./rgw-user-details.component.scss']
+})
+export class RgwUserDetailsComponent implements OnChanges, OnInit {
+ @ViewChild('accessKeyTpl')
+ public accessKeyTpl: TemplateRef<any>;
+ @ViewChild('secretKeyTpl')
+ public secretKeyTpl: TemplateRef<any>;
+
+ @Input()
+ selection: any;
+
+ // Details tab
+ user: any;
+ maxBucketsMap: {};
+
+ // Keys tab
+ keys: any = [];
+ keysColumns: CdTableColumn[] = [];
+ keysSelection: CdTableSelection = new CdTableSelection();
+
+ icons = Icons;
+
+ constructor(private rgwUserService: RgwUserService, private modalService: ModalService) {}
+
+ ngOnInit() {
+ this.keysColumns = [
+ {
+ name: $localize`Username`,
+ prop: 'username',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Type`,
+ prop: 'type',
+ flexGrow: 1
+ }
+ ];
+ this.maxBucketsMap = {
+ '-1': $localize`Disabled`,
+ 0: $localize`Unlimited`
+ };
+ }
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.user = this.selection;
+
+ // Sort subusers and capabilities.
+ this.user.subusers = _.sortBy(this.user.subusers, 'id');
+ this.user.caps = _.sortBy(this.user.caps, 'type');
+
+ // Load the user/bucket quota of the selected user.
+ this.rgwUserService.getQuota(this.user.uid).subscribe((resp: object) => {
+ _.extend(this.user, resp);
+ });
+
+ // Process the keys.
+ this.keys = [];
+ if (this.user.keys) {
+ this.user.keys.forEach((key: RgwUserS3Key) => {
+ this.keys.push({
+ id: this.keys.length + 1, // Create an unique identifier
+ type: 'S3',
+ username: key.user,
+ ref: key
+ });
+ });
+ }
+ if (this.user.swift_keys) {
+ this.user.swift_keys.forEach((key: RgwUserSwiftKey) => {
+ this.keys.push({
+ id: this.keys.length + 1, // Create an unique identifier
+ type: 'Swift',
+ username: key.user,
+ ref: key
+ });
+ });
+ }
+
+ this.keys = _.sortBy(this.keys, 'user');
+ }
+ }
+
+ updateKeysSelection(selection: CdTableSelection) {
+ this.keysSelection = selection;
+ }
+
+ showKeyModal() {
+ const key = this.keysSelection.first();
+ const modalRef = this.modalService.show(
+ key.type === 'S3' ? RgwUserS3KeyModalComponent : RgwUserSwiftKeyModalComponent
+ );
+ switch (key.type) {
+ case 'S3':
+ modalRef.componentInstance.setViewing();
+ modalRef.componentInstance.setValues(key.ref.user, key.ref.access_key, key.ref.secret_key);
+ break;
+ case 'Swift':
+ modalRef.componentInstance.setValues(key.ref.user, key.ref.secret_key);
+ break;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
new file mode 100644
index 000000000..4a28c3e57
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
@@ -0,0 +1,668 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form #frm="ngForm"
+ [formGroup]="userForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- User ID -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !editing}"
+ for="user_id"
+ i18n>User ID</label>
+ <div class="cd-col-form-input">
+ <input id="user_id"
+ class="form-control"
+ type="text"
+ formControlName="user_id"
+ [readonly]="editing">
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('user_id', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('user_id', frm, 'pattern')"
+ i18n>The value is not valid.</span>
+ <span class="invalid-feedback"
+ *ngIf="!userForm.getValue('show_tenant') && userForm.showError('user_id', frm, 'notUnique')"
+ i18n>The chosen user ID is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Show Tenant -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="show_tenant"
+ type="checkbox"
+ (click)="updateFieldsWhenTenanted()"
+ formControlName="show_tenant"
+ [readonly]="true">
+ <label class="custom-control-label"
+ for="show_tenant"
+ i18n>Show Tenant</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Tenant -->
+ <div class="form-group row"
+ *ngIf="userForm.getValue('show_tenant')">
+ <label class="cd-col-form-label"
+ for="tenant"
+ i18n>Tenant</label>
+ <div class="cd-col-form-input">
+ <input id="tenant"
+ class="form-control"
+ type="text"
+ formControlName="tenant"
+ [readonly]="editing"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('tenant', frm, 'pattern')"
+ i18n>The value is not valid.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('tenant', frm, 'notUnique')"
+ i18n>The chosen user ID exists in this tenant.</span>
+ </div>
+ </div>
+
+ <!-- Full name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !editing}"
+ for="display_name"
+ i18n>Full name</label>
+ <div class="cd-col-form-input">
+ <input id="display_name"
+ class="form-control"
+ type="text"
+ formControlName="display_name">
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('display_name', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Email address -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="email"
+ i18n>Email address</label>
+ <div class="cd-col-form-input">
+ <input id="email"
+ class="form-control"
+ type="text"
+ formControlName="email">
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('email', frm, 'email')"
+ i18n>This is not a valid email address.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('email', frm, 'notUnique')"
+ i18n>The chosen email address is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Max. buckets -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="max_buckets_mode"
+ i18n>Max. buckets</label>
+ <div class="cd-col-form-input">
+ <select class="form-control"
+ formControlName="max_buckets_mode"
+ name="max_buckets_mode"
+ id="max_buckets_mode"
+ (change)="onMaxBucketsModeChange($event.target.value)">
+ <option i18n
+ value="-1">Disabled</option>
+ <option i18n
+ value="0">Unlimited</option>
+ <option i18n
+ value="1">Custom</option>
+ </select>
+ </div>
+ </div>
+ <div *ngIf="1 == userForm.get('max_buckets_mode').value"
+ class="form-group row">
+ <label class="cd-col-form-label"></label>
+ <div class="cd-col-form-input">
+ <input id="max_buckets"
+ class="form-control"
+ type="number"
+ formControlName="max_buckets"
+ min="1">
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('max_buckets', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('max_buckets', frm, 'min')"
+ i18n>The entered value must be >= 1.</span>
+ </div>
+ </div>
+
+ <!-- Suspended -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="suspended"
+ type="checkbox"
+ formControlName="suspended">
+ <label class="custom-control-label"
+ for="suspended"
+ i18n>Suspended</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- S3 key -->
+ <fieldset *ngIf="!editing">
+ <legend i18n>S3 key</legend>
+
+ <!-- Auto-generate key -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="generate_key"
+ type="checkbox"
+ formControlName="generate_key">
+ <label class="custom-control-label"
+ for="generate_key"
+ i18n>Auto-generate key</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Access key -->
+ <div class="form-group row"
+ *ngIf="!editing && !userForm.getValue('generate_key')">
+ <label class="cd-col-form-label required"
+ for="access_key"
+ i18n>Access key</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="access_key"
+ class="form-control"
+ type="password"
+ formControlName="access_key">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="access_key">
+ </button>
+ <cd-copy-2-clipboard-button source="access_key">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('access_key', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Secret key -->
+ <div class="form-group row"
+ *ngIf="!editing && !userForm.getValue('generate_key')">
+ <label class="cd-col-form-label required"
+ for="secret_key"
+ i18n>Secret key</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="secret_key"
+ class="form-control"
+ type="password"
+ formControlName="secret_key">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="secret_key">
+ </button>
+ <cd-copy-2-clipboard-button source="secret_key">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('secret_key', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Subusers -->
+ <fieldset *ngIf="editing">
+ <legend i18n>Subusers</legend>
+ <div class="row">
+ <div class="cd-col-form-offset">
+ <span *ngIf="subusers.length === 0"
+ class="no-border">
+ <span class="form-text text-muted"
+ i18n>There are no subusers.</span>
+ </span>
+
+ <span *ngFor="let subuser of subusers; let i=index;">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text">
+ <i class="{{ icons.user }}"></i>
+ </span>
+ </div>
+ <input type="text"
+ class="cd-form-control"
+ value="{{ subuser.id }}"
+ readonly>
+ <div class="input-group-prepend border-left-0 border-right-0">
+ <span class="input-group-text">
+ <i class="{{ icons.share }}"></i>
+ </span>
+ </div>
+ <input type="text"
+ class="cd-form-control"
+ value="{{ ('full-control' === subuser.permissions) ? 'full' : subuser.permissions }}"
+ readonly>
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light tc_showSubuserButton"
+ i18n-ngbTooltip
+ ngbTooltip="Edit"
+ (click)="showSubuserModal(i)">
+ <i [ngClass]="[icons.edit]"></i>
+ </button>
+ <button type="button"
+ class="btn btn-light tc_deleteSubuserButton"
+ i18n-ngbTooltip
+ ngbTooltip="Delete"
+ (click)="deleteSubuser(i)">
+ <i [ngClass]="[icons.destroy]"></i>
+ </button>
+ </span>
+ </div>
+ <span class="form-text text-muted"></span>
+ </span>
+
+ <div class="row">
+ <div class="col-12">
+ <button type="button"
+ class="btn btn-light float-right tc_addSubuserButton"
+ (click)="showSubuserModal()">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>{{ actionLabels.CREATE | titlecase }}
+ {{ subuserLabel | upperFirst }}</ng-container>
+ </button>
+ </div>
+ </div>
+ <span class="help-block"></span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Keys -->
+ <fieldset *ngIf="editing">
+ <legend i18n>Keys</legend>
+
+ <!-- S3 keys -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ i18n>S3</label>
+ <div class="cd-col-form-input">
+ <span *ngIf="s3Keys.length === 0"
+ class="no-border">
+ <span class="form-text text-muted"
+ i18n>There are no keys.</span>
+ </span>
+
+ <span *ngFor="let key of s3Keys; let i=index;">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <div class="input-group-text">
+ <i class="{{ icons.key }}"></i>
+ </div>
+ </div>
+ <input type="text"
+ class="cd-form-control"
+ value="{{ key.user }}"
+ readonly>
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light tc_showS3KeyButton"
+ i18n-ngbTooltip
+ ngbTooltip="Show"
+ (click)="showS3KeyModal(i)">
+ <i [ngClass]="[icons.show]"></i>
+ </button>
+ <button type="button"
+ class="btn btn-light tc_deleteS3KeyButton"
+ i18n-ngbTooltip
+ ngbTooltip="Delete"
+ (click)="deleteS3Key(i)">
+ <i [ngClass]="[icons.destroy]"></i>
+ </button>
+ </span>
+ </div>
+ <span class="form-text text-muted"></span>
+ </span>
+
+ <div class="row">
+ <div class="col-12">
+ <button type="button"
+ class="btn btn-light float-right tc_addS3KeyButton"
+ (click)="showS3KeyModal()">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>{{ actionLabels.CREATE | titlecase }}
+ {{ s3keyLabel | upperFirst }}</ng-container>
+ </button>
+ </div>
+ </div>
+
+ <span class="help-block"></span>
+ </div>
+
+ <hr>
+ </div>
+
+ <!-- Swift keys -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ i18n>Swift</label>
+
+ <div class="cd-col-form-input">
+ <span *ngIf="swiftKeys.length === 0"
+ class="no-border">
+ <span class="form-text text-muted"
+ i18n>There are no keys.</span>
+ </span>
+
+ <span *ngFor="let key of swiftKeys; let i=index;">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text">
+ <i class="{{ icons.key }}"></i>
+ </span>
+ </div>
+ <input type="text"
+ class="cd-form-control"
+ value="{{ key.user }}"
+ readonly>
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light tc_showSwiftKeyButton"
+ i18n-ngbTooltip
+ ngbTooltip="Show"
+ (click)="showSwiftKeyModal(i)">
+ <i [ngClass]="[icons.show]"></i>
+ </button>
+ </span>
+ </div>
+ <span class="form-text text-muted"></span>
+ </span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Capabilities -->
+ <fieldset *ngIf="editing">
+ <legend i18n>Capabilities</legend>
+
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <span *ngIf="capabilities.length === 0"
+ class="no-border">
+ <span class="form-text text-muted"
+ i18n>There are no capabilities.</span>
+ </span>
+
+ <span *ngFor="let cap of capabilities; let i=index;">
+ <div class="input-group">
+ <span class="input-group-prepend">
+ <div class="input-group-text">
+ <i class="{{ icons.share }}"></i>
+ </div>
+ </span>
+ <input type="text"
+ class="cd-form-control"
+ value="{{ cap.type }}:{{ cap.perm }}"
+ readonly>
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light tc_editCapButton"
+ i18n-ngbTooltip
+ ngbTooltip="Edit"
+ (click)="showCapabilityModal(i)">
+ <i [ngClass]="[icons.edit]"></i>
+ </button>
+ <button type="button"
+ class="btn btn-light tc_deleteCapButton"
+ i18n-ngbTooltip
+ ngbTooltip="Delete"
+ (click)="deleteCapability(i)">
+ <i [ngClass]="[icons.destroy]"></i>
+ </button>
+ </span>
+ </div>
+ <span class="form-text text-muted"></span>
+ </span>
+
+ <div class="row">
+ <div class="col-12">
+ <button type="button"
+ class="btn btn-light float-right tc_addCapButton"
+ [disabled]="capabilities | pipeFunction:hasAllCapabilities"
+ i18n-ngbTooltip
+ ngbTooltip="All capabilities are already added."
+ [disableTooltip]="!(capabilities | pipeFunction:hasAllCapabilities)"
+ triggers="pointerenter:pointerleave"
+ (click)="showCapabilityModal()">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>{{ actionLabels.ADD | titlecase }}
+ {{ capabilityLabel | upperFirst }}</ng-container>
+ </button>
+ </div>
+ </div>
+ <span class="help-block"></span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- User quota -->
+ <fieldset>
+ <legend i18n>User quota</legend>
+
+ <!-- Enabled -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="user_quota_enabled"
+ type="checkbox"
+ formControlName="user_quota_enabled">
+ <label class="custom-control-label"
+ for="user_quota_enabled"
+ i18n>Enabled</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Unlimited size -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.user_quota_enabled.value">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="user_quota_max_size_unlimited"
+ type="checkbox"
+ formControlName="user_quota_max_size_unlimited">
+ <label class="custom-control-label"
+ for="user_quota_max_size_unlimited"
+ i18n>Unlimited size</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Maximum size -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.user_quota_enabled.value && !userForm.getValue('user_quota_max_size_unlimited')">
+ <label class="cd-col-form-label required"
+ for="user_quota_max_size"
+ i18n>Max. size</label>
+ <div class="cd-col-form-input">
+ <input id="user_quota_max_size"
+ class="form-control"
+ type="text"
+ formControlName="user_quota_max_size"
+ cdDimlessBinary>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('user_quota_max_size', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('user_quota_max_size', frm, 'quotaMaxSize')"
+ i18n>The value is not valid.</span>
+ </div>
+ </div>
+
+ <!-- Unlimited objects -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.user_quota_enabled.value">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="user_quota_max_objects_unlimited"
+ type="checkbox"
+ formControlName="user_quota_max_objects_unlimited">
+ <label class="custom-control-label"
+ for="user_quota_max_objects_unlimited"
+ i18n>Unlimited objects</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Maximum objects -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.user_quota_enabled.value && !userForm.getValue('user_quota_max_objects_unlimited')">
+ <label class="cd-col-form-label required"
+ for="user_quota_max_objects"
+ i18n>Max. objects</label>
+ <div class="cd-col-form-input">
+ <input id="user_quota_max_objects"
+ class="form-control"
+ type="number"
+ formControlName="user_quota_max_objects"
+ min="0">
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('user_quota_max_objects', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('user_quota_max_objects', frm, 'min')"
+ i18n>The entered value must be >= 0.</span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Bucket quota -->
+ <fieldset>
+ <legend i18n>Bucket quota</legend>
+
+ <!-- Enabled -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="bucket_quota_enabled"
+ type="checkbox"
+ formControlName="bucket_quota_enabled">
+ <label class="custom-control-label"
+ for="bucket_quota_enabled"
+ i18n>Enabled</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Unlimited size -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.bucket_quota_enabled.value">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="bucket_quota_max_size_unlimited"
+ type="checkbox"
+ formControlName="bucket_quota_max_size_unlimited">
+ <label class="custom-control-label"
+ for="bucket_quota_max_size_unlimited"
+ i18n>Unlimited size</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Maximum size -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.bucket_quota_enabled.value && !userForm.getValue('bucket_quota_max_size_unlimited')">
+ <label class="cd-col-form-label required"
+ for="bucket_quota_max_size"
+ i18n>Max. size</label>
+ <div class="cd-col-form-input">
+ <input id="bucket_quota_max_size"
+ class="form-control"
+ type="text"
+ formControlName="bucket_quota_max_size"
+ cdDimlessBinary>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('bucket_quota_max_size', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('bucket_quota_max_size', frm, 'quotaMaxSize')"
+ i18n>The value is not valid.</span>
+ </div>
+ </div>
+
+ <!-- Unlimited objects -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.bucket_quota_enabled.value">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="bucket_quota_max_objects_unlimited"
+ type="checkbox"
+ formControlName="bucket_quota_max_objects_unlimited">
+ <label class="custom-control-label"
+ for="bucket_quota_max_objects_unlimited"
+ i18n>Unlimited objects</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Maximum objects -->
+ <div class="form-group row"
+ *ngIf="userForm.controls.bucket_quota_enabled.value && !userForm.getValue('bucket_quota_max_objects_unlimited')">
+ <label class="cd-col-form-label required"
+ for="bucket_quota_max_objects"
+ i18n>Max. objects</label>
+ <div class="cd-col-form-input">
+ <input id="bucket_quota_max_objects"
+ class="form-control"
+ type="number"
+ formControlName="bucket_quota_max_objects"
+ min="0">
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('bucket_quota_max_objects', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('bucket_quota_max_objects', frm, 'min')"
+ i18n>The entered value must be >= 0.</span>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="userForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts
new file mode 100644
index 000000000..15665d53b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts
@@ -0,0 +1,339 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { ToastrModule } from 'ngx-toastr';
+import { of as observableOf, throwError } from 'rxjs';
+
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { RgwUserCapabilities } from '../models/rgw-user-capabilities';
+import { RgwUserCapability } from '../models/rgw-user-capability';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+import { RgwUserFormComponent } from './rgw-user-form.component';
+
+describe('RgwUserFormComponent', () => {
+ let component: RgwUserFormComponent;
+ let fixture: ComponentFixture<RgwUserFormComponent>;
+ let rgwUserService: RgwUserService;
+ let formHelper: FormHelper;
+
+ configureTestBed({
+ declarations: [RgwUserFormComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbTooltipModule,
+ NgxPipeFunctionModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwUserFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ rgwUserService = TestBed.inject(RgwUserService);
+ formHelper = new FormHelper(component.userForm);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('s3 key management', () => {
+ beforeEach(() => {
+ spyOn(rgwUserService, 'addS3Key').and.stub();
+ });
+
+ it('should not update key', () => {
+ component.setS3Key(new RgwUserS3Key(), 3);
+ expect(component.s3Keys.length).toBe(0);
+ expect(rgwUserService.addS3Key).not.toHaveBeenCalled();
+ });
+
+ it('should set user defined key', () => {
+ const key = new RgwUserS3Key();
+ key.user = 'test1:subuser2';
+ key.access_key = 'my-access-key';
+ key.secret_key = 'my-secret-key';
+ component.setS3Key(key);
+ expect(component.s3Keys.length).toBe(1);
+ expect(component.s3Keys[0].user).toBe('test1:subuser2');
+ expect(rgwUserService.addS3Key).toHaveBeenCalledWith('test1', {
+ subuser: 'subuser2',
+ generate_key: 'false',
+ access_key: 'my-access-key',
+ secret_key: 'my-secret-key'
+ });
+ });
+
+ it('should set params for auto-generating key', () => {
+ const key = new RgwUserS3Key();
+ key.user = 'test1:subuser2';
+ key.generate_key = true;
+ key.access_key = 'my-access-key';
+ key.secret_key = 'my-secret-key';
+ component.setS3Key(key);
+ expect(component.s3Keys.length).toBe(1);
+ expect(component.s3Keys[0].user).toBe('test1:subuser2');
+ expect(rgwUserService.addS3Key).toHaveBeenCalledWith('test1', {
+ subuser: 'subuser2',
+ generate_key: 'true'
+ });
+ });
+
+ it('should set key w/o subuser', () => {
+ const key = new RgwUserS3Key();
+ key.user = 'test1';
+ component.setS3Key(key);
+ expect(component.s3Keys.length).toBe(1);
+ expect(component.s3Keys[0].user).toBe('test1');
+ expect(rgwUserService.addS3Key).toHaveBeenCalledWith('test1', {
+ subuser: '',
+ generate_key: 'false',
+ access_key: undefined,
+ secret_key: undefined
+ });
+ });
+ });
+
+ describe('quotaMaxSizeValidator', () => {
+ it('should validate max size (1)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl(''));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate max size (2)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('xxxx'));
+ expect(resp.quotaMaxSize).toBeTruthy();
+ });
+
+ it('should validate max size (3)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('1023'));
+ expect(resp.quotaMaxSize).toBeTruthy();
+ });
+
+ it('should validate max size (4)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('1024'));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate max size (5)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('1M'));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate max size (6)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('1024 gib'));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate max size (7)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('10 X'));
+ expect(resp.quotaMaxSize).toBeTruthy();
+ });
+
+ it('should validate max size (8)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('1.085 GiB'));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate max size (9)', () => {
+ const resp = component.quotaMaxSizeValidator(new FormControl('1,085 GiB'));
+ expect(resp.quotaMaxSize).toBeTruthy();
+ });
+ });
+
+ describe('username validation', () => {
+ it('should validate that username is required', () => {
+ formHelper.expectErrorChange('user_id', '', 'required', true);
+ });
+
+ it('should validate that username is valid', fakeAsync(() => {
+ spyOn(rgwUserService, 'get').and.returnValue(throwError('foo'));
+ formHelper.setValue('user_id', 'ab', true);
+ tick();
+ formHelper.expectValid('user_id');
+ }));
+
+ it('should validate that username is invalid', fakeAsync(() => {
+ spyOn(rgwUserService, 'get').and.returnValue(observableOf({}));
+ formHelper.setValue('user_id', 'abc', true);
+ tick();
+ formHelper.expectError('user_id', 'notUnique');
+ }));
+ });
+
+ describe('max buckets', () => {
+ it('disable creation (create)', () => {
+ spyOn(rgwUserService, 'create');
+ formHelper.setValue('max_buckets_mode', -1, true);
+ component.onSubmit();
+ expect(rgwUserService.create).toHaveBeenCalledWith({
+ access_key: '',
+ display_name: null,
+ email: '',
+ generate_key: true,
+ max_buckets: -1,
+ secret_key: '',
+ suspended: false,
+ uid: null
+ });
+ });
+
+ it('disable creation (edit)', () => {
+ spyOn(rgwUserService, 'update');
+ component.editing = true;
+ formHelper.setValue('max_buckets_mode', -1, true);
+ component.onSubmit();
+ expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+ display_name: null,
+ email: null,
+ max_buckets: -1,
+ suspended: false
+ });
+ });
+
+ it('unlimited buckets (create)', () => {
+ spyOn(rgwUserService, 'create');
+ formHelper.setValue('max_buckets_mode', 0, true);
+ component.onSubmit();
+ expect(rgwUserService.create).toHaveBeenCalledWith({
+ access_key: '',
+ display_name: null,
+ email: '',
+ generate_key: true,
+ max_buckets: 0,
+ secret_key: '',
+ suspended: false,
+ uid: null
+ });
+ });
+
+ it('unlimited buckets (edit)', () => {
+ spyOn(rgwUserService, 'update');
+ component.editing = true;
+ formHelper.setValue('max_buckets_mode', 0, true);
+ component.onSubmit();
+ expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+ display_name: null,
+ email: null,
+ max_buckets: 0,
+ suspended: false
+ });
+ });
+
+ it('custom (create)', () => {
+ spyOn(rgwUserService, 'create');
+ formHelper.setValue('max_buckets_mode', 1, true);
+ formHelper.setValue('max_buckets', 100, true);
+ component.onSubmit();
+ expect(rgwUserService.create).toHaveBeenCalledWith({
+ access_key: '',
+ display_name: null,
+ email: '',
+ generate_key: true,
+ max_buckets: 100,
+ secret_key: '',
+ suspended: false,
+ uid: null
+ });
+ });
+
+ it('custom (edit)', () => {
+ spyOn(rgwUserService, 'update');
+ component.editing = true;
+ formHelper.setValue('max_buckets_mode', 1, true);
+ formHelper.setValue('max_buckets', 100, true);
+ component.onSubmit();
+ expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+ display_name: null,
+ email: null,
+ max_buckets: 100,
+ suspended: false
+ });
+ });
+ });
+
+ describe('submit form', () => {
+ let notificationService: NotificationService;
+
+ beforeEach(() => {
+ spyOn(TestBed.inject(Router), 'navigate').and.stub();
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show');
+ });
+
+ it('should be able to clear the mail field on update', () => {
+ spyOn(rgwUserService, 'update');
+ component.editing = true;
+ formHelper.setValue('email', '', true);
+ component.onSubmit();
+ expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+ display_name: null,
+ email: '',
+ max_buckets: 1000,
+ suspended: false
+ });
+ });
+
+ it('tests create success notification', () => {
+ spyOn(rgwUserService, 'create').and.returnValue(observableOf([]));
+ component.editing = false;
+ formHelper.setValue('suspended', true, true);
+ component.onSubmit();
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `Created Object Gateway user 'null'`
+ );
+ });
+
+ it('tests update success notification', () => {
+ spyOn(rgwUserService, 'update').and.returnValue(observableOf([]));
+ component.editing = true;
+ formHelper.setValue('suspended', true, true);
+ component.onSubmit();
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `Updated Object Gateway user 'null'`
+ );
+ });
+ });
+
+ describe('RgwUserCapabilities', () => {
+ it('capability button disabled when all capabilities are added', () => {
+ component.editing = true;
+ for (const capabilityType of RgwUserCapabilities.getAll()) {
+ const capability = new RgwUserCapability();
+ capability.type = capabilityType;
+ capability.perm = 'read';
+ component.setCapability(capability);
+ }
+
+ fixture.detectChanges();
+
+ expect(component.hasAllCapabilities(component.capabilities)).toBeTruthy();
+ const capabilityButton = fixture.debugElement.nativeElement.querySelector('.tc_addCapButton');
+ expect(capabilityButton.disabled).toBeTruthy();
+ });
+
+ it('capability button not disabled when not all capabilities are added', () => {
+ component.editing = true;
+
+ fixture.detectChanges();
+
+ const capabilityButton = fixture.debugElement.nativeElement.querySelector('.tc_addCapButton');
+ expect(capabilityButton.disabled).toBeFalsy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts
new file mode 100644
index 000000000..5ab333771
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts
@@ -0,0 +1,756 @@
+import { Component, OnInit } from '@angular/core';
+import { AbstractControl, ValidationErrors, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import { concat as observableConcat, forkJoin as observableForkJoin, Observable } from 'rxjs';
+
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators, isEmptyInputValue } from '~/app/shared/forms/cd-validators';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { RgwUserCapabilities } from '../models/rgw-user-capabilities';
+import { RgwUserCapability } from '../models/rgw-user-capability';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+import { RgwUserSubuser } from '../models/rgw-user-subuser';
+import { RgwUserSwiftKey } from '../models/rgw-user-swift-key';
+import { RgwUserCapabilityModalComponent } from '../rgw-user-capability-modal/rgw-user-capability-modal.component';
+import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
+import { RgwUserSubuserModalComponent } from '../rgw-user-subuser-modal/rgw-user-subuser-modal.component';
+import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
+
+@Component({
+ selector: 'cd-rgw-user-form',
+ templateUrl: './rgw-user-form.component.html',
+ styleUrls: ['./rgw-user-form.component.scss']
+})
+export class RgwUserFormComponent extends CdForm implements OnInit {
+ userForm: CdFormGroup;
+ editing = false;
+ submitObservables: Observable<Object>[] = [];
+ icons = Icons;
+ subusers: RgwUserSubuser[] = [];
+ s3Keys: RgwUserS3Key[] = [];
+ swiftKeys: RgwUserSwiftKey[] = [];
+ capabilities: RgwUserCapability[] = [];
+
+ action: string;
+ resource: string;
+ subuserLabel: string;
+ s3keyLabel: string;
+ capabilityLabel: string;
+ usernameExists: boolean;
+ showTenant = false;
+ previousTenant: string = null;
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ private route: ActivatedRoute,
+ private router: Router,
+ private rgwUserService: RgwUserService,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.resource = $localize`user`;
+ this.subuserLabel = $localize`subuser`;
+ this.s3keyLabel = $localize`S3 Key`;
+ this.capabilityLabel = $localize`capability`;
+ this.editing = this.router.url.startsWith(`/rgw/user/${URLVerbs.EDIT}`);
+ this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
+ this.createForm();
+ }
+
+ createForm() {
+ this.userForm = this.formBuilder.group({
+ // General
+ user_id: [
+ null,
+ [Validators.required, Validators.pattern(/^[a-zA-Z0-9!@#%^&*()_-]+$/)],
+ this.editing
+ ? []
+ : [
+ CdValidators.unique(this.rgwUserService.exists, this.rgwUserService, () =>
+ this.userForm.getValue('tenant')
+ )
+ ]
+ ],
+ show_tenant: [this.editing],
+ tenant: [
+ null,
+ [Validators.pattern(/^[a-zA-Z0-9!@#%^&*()_-]+$/)],
+ this.editing
+ ? []
+ : [
+ CdValidators.unique(
+ this.rgwUserService.exists,
+ this.rgwUserService,
+ () => this.userForm.getValue('user_id'),
+ true
+ )
+ ]
+ ],
+ display_name: [null, [Validators.required]],
+ email: [
+ null,
+ [CdValidators.email],
+ [CdValidators.unique(this.rgwUserService.emailExists, this.rgwUserService)]
+ ],
+ max_buckets_mode: [1],
+ max_buckets: [
+ 1000,
+ [CdValidators.requiredIf({ max_buckets_mode: '1' }), CdValidators.number(false)]
+ ],
+ suspended: [false],
+ // S3 key
+ generate_key: [true],
+ access_key: [null, [CdValidators.requiredIf({ generate_key: false })]],
+ secret_key: [null, [CdValidators.requiredIf({ generate_key: false })]],
+ // User quota
+ user_quota_enabled: [false],
+ user_quota_max_size_unlimited: [true],
+ user_quota_max_size: [
+ null,
+ [
+ CdValidators.composeIf(
+ {
+ user_quota_enabled: true,
+ user_quota_max_size_unlimited: false
+ },
+ [Validators.required, this.quotaMaxSizeValidator]
+ )
+ ]
+ ],
+ user_quota_max_objects_unlimited: [true],
+ user_quota_max_objects: [
+ null,
+ [
+ CdValidators.requiredIf({
+ user_quota_enabled: true,
+ user_quota_max_objects_unlimited: false
+ })
+ ]
+ ],
+ // Bucket quota
+ bucket_quota_enabled: [false],
+ bucket_quota_max_size_unlimited: [true],
+ bucket_quota_max_size: [
+ null,
+ [
+ CdValidators.composeIf(
+ {
+ bucket_quota_enabled: true,
+ bucket_quota_max_size_unlimited: false
+ },
+ [Validators.required, this.quotaMaxSizeValidator]
+ )
+ ]
+ ],
+ bucket_quota_max_objects_unlimited: [true],
+ bucket_quota_max_objects: [
+ null,
+ [
+ CdValidators.requiredIf({
+ bucket_quota_enabled: true,
+ bucket_quota_max_objects_unlimited: false
+ })
+ ]
+ ]
+ });
+ }
+
+ ngOnInit() {
+ // Process route parameters.
+ this.route.params.subscribe((params: { uid: string }) => {
+ if (!params.hasOwnProperty('uid')) {
+ this.loadingReady();
+ return;
+ }
+ const uid = decodeURIComponent(params.uid);
+ // Load the user and quota information.
+ const observables = [];
+ observables.push(this.rgwUserService.get(uid));
+ observables.push(this.rgwUserService.getQuota(uid));
+ observableForkJoin(observables).subscribe(
+ (resp: any[]) => {
+ // Get the default values.
+ const defaults = _.clone(this.userForm.value);
+ // Extract the values displayed in the form.
+ let value = _.pick(resp[0], _.keys(this.userForm.value));
+ // Map the max. buckets values.
+ switch (value['max_buckets']) {
+ case -1:
+ value['max_buckets_mode'] = -1;
+ value['max_buckets'] = '';
+ break;
+ case 0:
+ value['max_buckets_mode'] = 0;
+ value['max_buckets'] = '';
+ break;
+ default:
+ value['max_buckets_mode'] = 1;
+ break;
+ }
+ // Map the quota values.
+ ['user', 'bucket'].forEach((type) => {
+ const quota = resp[1][type + '_quota'];
+ value[type + '_quota_enabled'] = quota.enabled;
+ if (quota.max_size < 0) {
+ value[type + '_quota_max_size_unlimited'] = true;
+ value[type + '_quota_max_size'] = null;
+ } else {
+ value[type + '_quota_max_size_unlimited'] = false;
+ value[type + '_quota_max_size'] = `${quota.max_size} B`;
+ }
+ if (quota.max_objects < 0) {
+ value[type + '_quota_max_objects_unlimited'] = true;
+ value[type + '_quota_max_objects'] = null;
+ } else {
+ value[type + '_quota_max_objects_unlimited'] = false;
+ value[type + '_quota_max_objects'] = quota.max_objects;
+ }
+ });
+ // Merge with default values.
+ value = _.merge(defaults, value);
+ // Update the form.
+ this.userForm.setValue(value);
+
+ // Get the sub users.
+ this.subusers = resp[0].subusers;
+
+ // Get the keys.
+ this.s3Keys = resp[0].keys;
+ this.swiftKeys = resp[0].swift_keys;
+
+ // Process the capabilities.
+ const mapPerm = { 'read, write': '*' };
+ resp[0].caps.forEach((cap: any) => {
+ if (cap.perm in mapPerm) {
+ cap.perm = mapPerm[cap.perm];
+ }
+ });
+ this.capabilities = resp[0].caps;
+
+ this.loadingReady();
+ },
+ () => {
+ this.loadingError();
+ }
+ );
+ });
+ }
+
+ goToListView() {
+ this.router.navigate(['/rgw/user']);
+ }
+
+ onSubmit() {
+ let notificationTitle: string;
+ // Exit immediately if the form isn't dirty.
+ if (this.userForm.pristine) {
+ this.goToListView();
+ return;
+ }
+ const uid = this.getUID();
+ if (this.editing) {
+ // Edit
+ if (this._isGeneralDirty()) {
+ const args = this._getUpdateArgs();
+ this.submitObservables.push(this.rgwUserService.update(uid, args));
+ }
+ notificationTitle = $localize`Updated Object Gateway user '${uid}'`;
+ } else {
+ // Add
+ const args = this._getCreateArgs();
+ this.submitObservables.push(this.rgwUserService.create(args));
+ notificationTitle = $localize`Created Object Gateway user '${uid}'`;
+ }
+ // Check if user quota has been modified.
+ if (this._isUserQuotaDirty()) {
+ const userQuotaArgs = this._getUserQuotaArgs();
+ this.submitObservables.push(this.rgwUserService.updateQuota(uid, userQuotaArgs));
+ }
+ // Check if bucket quota has been modified.
+ if (this._isBucketQuotaDirty()) {
+ const bucketQuotaArgs = this._getBucketQuotaArgs();
+ this.submitObservables.push(this.rgwUserService.updateQuota(uid, bucketQuotaArgs));
+ }
+ // Finally execute all observables one by one in serial.
+ observableConcat(...this.submitObservables).subscribe({
+ error: () => {
+ // Reset the 'Submit' button.
+ this.userForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.notificationService.show(NotificationType.success, notificationTitle);
+ this.goToListView();
+ }
+ });
+ }
+
+ updateFieldsWhenTenanted() {
+ this.showTenant = this.userForm.getValue('show_tenant');
+ if (!this.showTenant) {
+ this.userForm.get('user_id').markAsUntouched();
+ this.userForm.get('tenant').patchValue(this.previousTenant);
+ } else {
+ this.userForm.get('user_id').markAsTouched();
+ this.previousTenant = this.userForm.get('tenant').value;
+ this.userForm.get('tenant').patchValue(null);
+ }
+ }
+
+ getUID(): string {
+ let uid = this.userForm.getValue('user_id');
+ const tenant = this.userForm?.getValue('tenant');
+ if (tenant && tenant.length > 0) {
+ uid = `${this.userForm.getValue('tenant')}$${uid}`;
+ }
+ return uid;
+ }
+
+ /**
+ * Validate the quota maximum size, e.g. 1096, 1K, 30M or 1.9MiB.
+ */
+ quotaMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
+ if (isEmptyInputValue(control.value)) {
+ return null;
+ }
+ const m = RegExp('^(\\d+(\\.\\d+)?)\\s*(B|K(B|iB)?|M(B|iB)?|G(B|iB)?|T(B|iB)?)?$', 'i').exec(
+ control.value
+ );
+ if (m === null) {
+ return { quotaMaxSize: true };
+ }
+ const bytes = new FormatterService().toBytes(control.value);
+ return bytes < 1024 ? { quotaMaxSize: true } : null;
+ }
+
+ /**
+ * Add/Update a subuser.
+ */
+ setSubuser(subuser: RgwUserSubuser, index?: number) {
+ const mapPermissions: Record<string, string> = {
+ 'full-control': 'full',
+ 'read-write': 'readwrite'
+ };
+ const uid = this.getUID();
+ const args = {
+ subuser: subuser.id,
+ access:
+ subuser.permissions in mapPermissions
+ ? mapPermissions[subuser.permissions]
+ : subuser.permissions,
+ key_type: 'swift',
+ secret_key: subuser.secret_key,
+ generate_secret: subuser.generate_secret ? 'true' : 'false'
+ };
+ this.submitObservables.push(this.rgwUserService.createSubuser(uid, args));
+ if (_.isNumber(index)) {
+ // Modify
+ // Create an observable to modify the subuser when the form is submitted.
+ this.subusers[index] = subuser;
+ } else {
+ // Add
+ // Create an observable to add the subuser when the form is submitted.
+ this.subusers.push(subuser);
+ // Add a Swift key. If the secret key is auto-generated, then visualize
+ // this to the user by displaying a notification instead of the key.
+ this.swiftKeys.push({
+ user: subuser.id,
+ secret_key: subuser.generate_secret ? 'Apply your changes first...' : subuser.secret_key
+ });
+ }
+ // Mark the form as dirty to be able to submit it.
+ this.userForm.markAsDirty();
+ }
+
+ /**
+ * Delete a subuser.
+ * @param {number} index The subuser to delete.
+ */
+ deleteSubuser(index: number) {
+ const subuser = this.subusers[index];
+ // Create an observable to delete the subuser when the form is submitted.
+ this.submitObservables.push(this.rgwUserService.deleteSubuser(this.getUID(), subuser.id));
+ // Remove the associated S3 keys.
+ this.s3Keys = this.s3Keys.filter((key) => {
+ return key.user !== subuser.id;
+ });
+ // Remove the associated Swift keys.
+ this.swiftKeys = this.swiftKeys.filter((key) => {
+ return key.user !== subuser.id;
+ });
+ // Remove the subuser to update the UI.
+ this.subusers.splice(index, 1);
+ // Mark the form as dirty to be able to submit it.
+ this.userForm.markAsDirty();
+ }
+
+ /**
+ * Add/Update a capability.
+ */
+ setCapability(cap: RgwUserCapability, index?: number) {
+ const uid = this.getUID();
+ if (_.isNumber(index)) {
+ // Modify
+ const oldCap = this.capabilities[index];
+ // Note, the RadosGW Admin OPS API does not support the modification of
+ // user capabilities. Because of that it is necessary to delete it and
+ // then to re-add the capability with its new value/permission.
+ this.submitObservables.push(
+ this.rgwUserService.deleteCapability(uid, oldCap.type, oldCap.perm)
+ );
+ this.submitObservables.push(this.rgwUserService.addCapability(uid, cap.type, cap.perm));
+ this.capabilities[index] = cap;
+ } else {
+ // Add
+ // Create an observable to add the capability when the form is submitted.
+ this.submitObservables.push(this.rgwUserService.addCapability(uid, cap.type, cap.perm));
+ this.capabilities = [...this.capabilities, cap]; // Notify Angular CD
+ }
+ // Mark the form as dirty to be able to submit it.
+ this.userForm.markAsDirty();
+ }
+
+ /**
+ * Delete the given capability:
+ * - Delete it from the local array to update the UI
+ * - Create an observable that will be executed on form submit
+ * @param {number} index The capability to delete.
+ */
+ deleteCapability(index: number) {
+ const cap = this.capabilities[index];
+ // Create an observable to delete the capability when the form is submitted.
+ this.submitObservables.push(
+ this.rgwUserService.deleteCapability(this.getUID(), cap.type, cap.perm)
+ );
+ // Remove the capability to update the UI.
+ this.capabilities.splice(index, 1);
+ this.capabilities = [...this.capabilities]; // Notify Angular CD
+ // Mark the form as dirty to be able to submit it.
+ this.userForm.markAsDirty();
+ }
+
+ hasAllCapabilities(capabilities: RgwUserCapability[]) {
+ return !_.difference(RgwUserCapabilities.getAll(), _.map(capabilities, 'type')).length;
+ }
+
+ /**
+ * Add/Update a S3 key.
+ */
+ setS3Key(key: RgwUserS3Key, index?: number) {
+ if (_.isNumber(index)) {
+ // Modify
+ // Nothing to do here at the moment.
+ } else {
+ // Add
+ // Split the key's user name into its user and subuser parts.
+ const userMatches = key.user.match(/([^:]+)(:(.+))?/);
+ // Create an observable to add the S3 key when the form is submitted.
+ const uid = userMatches[1];
+ const args = {
+ subuser: userMatches[2] ? userMatches[3] : '',
+ generate_key: key.generate_key ? 'true' : 'false'
+ };
+ if (args['generate_key'] === 'false') {
+ if (!_.isNil(key.access_key)) {
+ args['access_key'] = key.access_key;
+ }
+ if (!_.isNil(key.secret_key)) {
+ args['secret_key'] = key.secret_key;
+ }
+ }
+ this.submitObservables.push(this.rgwUserService.addS3Key(uid, args));
+ // If the access and the secret key are auto-generated, then visualize
+ // this to the user by displaying a notification instead of the key.
+ this.s3Keys.push({
+ user: key.user,
+ access_key: key.generate_key ? 'Apply your changes first...' : key.access_key,
+ secret_key: key.generate_key ? 'Apply your changes first...' : key.secret_key
+ });
+ }
+ // Mark the form as dirty to be able to submit it.
+ this.userForm.markAsDirty();
+ }
+
+ /**
+ * Delete a S3 key.
+ * @param {number} index The S3 key to delete.
+ */
+ deleteS3Key(index: number) {
+ const key = this.s3Keys[index];
+ // Create an observable to delete the S3 key when the form is submitted.
+ this.submitObservables.push(this.rgwUserService.deleteS3Key(this.getUID(), key.access_key));
+ // Remove the S3 key to update the UI.
+ this.s3Keys.splice(index, 1);
+ // Mark the form as dirty to be able to submit it.
+ this.userForm.markAsDirty();
+ }
+
+ /**
+ * Show the specified subuser in a modal dialog.
+ * @param {number | undefined} index The subuser to show.
+ */
+ showSubuserModal(index?: number) {
+ const uid = this.getUID();
+ const modalRef = this.modalService.show(RgwUserSubuserModalComponent);
+ if (_.isNumber(index)) {
+ // Edit
+ const subuser = this.subusers[index];
+ modalRef.componentInstance.setEditing();
+ modalRef.componentInstance.setValues(uid, subuser.id, subuser.permissions);
+ } else {
+ // Add
+ modalRef.componentInstance.setEditing(false);
+ modalRef.componentInstance.setValues(uid);
+ modalRef.componentInstance.setSubusers(this.subusers);
+ }
+ modalRef.componentInstance.submitAction.subscribe((subuser: RgwUserSubuser) => {
+ this.setSubuser(subuser, index);
+ });
+ }
+
+ /**
+ * Show the specified S3 key in a modal dialog.
+ * @param {number | undefined} index The S3 key to show.
+ */
+ showS3KeyModal(index?: number) {
+ const modalRef = this.modalService.show(RgwUserS3KeyModalComponent);
+ if (_.isNumber(index)) {
+ // View
+ const key = this.s3Keys[index];
+ modalRef.componentInstance.setViewing();
+ modalRef.componentInstance.setValues(key.user, key.access_key, key.secret_key);
+ } else {
+ // Add
+ const candidates = this._getS3KeyUserCandidates();
+ modalRef.componentInstance.setViewing(false);
+ modalRef.componentInstance.setUserCandidates(candidates);
+ modalRef.componentInstance.submitAction.subscribe((key: RgwUserS3Key) => {
+ this.setS3Key(key);
+ });
+ }
+ }
+
+ /**
+ * Show the specified Swift key in a modal dialog.
+ * @param {number} index The Swift key to show.
+ */
+ showSwiftKeyModal(index: number) {
+ const modalRef = this.modalService.show(RgwUserSwiftKeyModalComponent);
+ const key = this.swiftKeys[index];
+ modalRef.componentInstance.setValues(key.user, key.secret_key);
+ }
+
+ /**
+ * Show the specified capability in a modal dialog.
+ * @param {number | undefined} index The S3 key to show.
+ */
+ showCapabilityModal(index?: number) {
+ const modalRef = this.modalService.show(RgwUserCapabilityModalComponent);
+ if (_.isNumber(index)) {
+ // Edit
+ const cap = this.capabilities[index];
+ modalRef.componentInstance.setEditing();
+ modalRef.componentInstance.setValues(cap.type, cap.perm);
+ } else {
+ // Add
+ modalRef.componentInstance.setEditing(false);
+ modalRef.componentInstance.setCapabilities(this.capabilities);
+ }
+ modalRef.componentInstance.submitAction.subscribe((cap: RgwUserCapability) => {
+ this.setCapability(cap, index);
+ });
+ }
+
+ /**
+ * Check if the general user settings (display name, email, ...) have been modified.
+ * @return {Boolean} Returns TRUE if the general user settings have been modified.
+ */
+ private _isGeneralDirty(): boolean {
+ return ['display_name', 'email', 'max_buckets_mode', 'max_buckets', 'suspended'].some(
+ (path) => {
+ return this.userForm.get(path).dirty;
+ }
+ );
+ }
+
+ /**
+ * Check if the user quota has been modified.
+ * @return {Boolean} Returns TRUE if the user quota has been modified.
+ */
+ private _isUserQuotaDirty(): boolean {
+ return [
+ 'user_quota_enabled',
+ 'user_quota_max_size_unlimited',
+ 'user_quota_max_size',
+ 'user_quota_max_objects_unlimited',
+ 'user_quota_max_objects'
+ ].some((path) => {
+ return this.userForm.get(path).dirty;
+ });
+ }
+
+ /**
+ * Check if the bucket quota has been modified.
+ * @return {Boolean} Returns TRUE if the bucket quota has been modified.
+ */
+ private _isBucketQuotaDirty(): boolean {
+ return [
+ 'bucket_quota_enabled',
+ 'bucket_quota_max_size_unlimited',
+ 'bucket_quota_max_size',
+ 'bucket_quota_max_objects_unlimited',
+ 'bucket_quota_max_objects'
+ ].some((path) => {
+ return this.userForm.get(path).dirty;
+ });
+ }
+
+ /**
+ * Helper function to get the arguments of the API request when a new
+ * user is created.
+ */
+ private _getCreateArgs() {
+ const result = {
+ uid: this.getUID(),
+ display_name: this.userForm.getValue('display_name'),
+ suspended: this.userForm.getValue('suspended'),
+ email: '',
+ max_buckets: this.userForm.getValue('max_buckets'),
+ generate_key: this.userForm.getValue('generate_key'),
+ access_key: '',
+ secret_key: ''
+ };
+ const email = this.userForm.getValue('email');
+ if (_.isString(email) && email.length > 0) {
+ _.merge(result, { email: email });
+ }
+ const generateKey = this.userForm.getValue('generate_key');
+ if (!generateKey) {
+ _.merge(result, {
+ generate_key: false,
+ access_key: this.userForm.getValue('access_key'),
+ secret_key: this.userForm.getValue('secret_key')
+ });
+ }
+ const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
+ if (_.includes([-1, 0], maxBucketsMode)) {
+ // -1 => Disable bucket creation.
+ // 0 => Unlimited bucket creation.
+ _.merge(result, { max_buckets: maxBucketsMode });
+ }
+ return result;
+ }
+
+ /**
+ * Helper function to get the arguments for the API request when the user
+ * configuration has been modified.
+ */
+ private _getUpdateArgs() {
+ const result: Record<string, any> = {};
+ const keys = ['display_name', 'email', 'max_buckets', 'suspended'];
+ for (const key of keys) {
+ result[key] = this.userForm.getValue(key);
+ }
+ const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
+ if (_.includes([-1, 0], maxBucketsMode)) {
+ // -1 => Disable bucket creation.
+ // 0 => Unlimited bucket creation.
+ result['max_buckets'] = maxBucketsMode;
+ }
+ return result;
+ }
+
+ /**
+ * Helper function to get the arguments for the API request when the user
+ * quota configuration has been modified.
+ */
+ private _getUserQuotaArgs(): Record<string, any> {
+ const result = {
+ quota_type: 'user',
+ enabled: this.userForm.getValue('user_quota_enabled'),
+ max_size_kb: -1,
+ max_objects: -1
+ };
+ if (!this.userForm.getValue('user_quota_max_size_unlimited')) {
+ // Convert the given value to bytes.
+ const bytes = new FormatterService().toBytes(this.userForm.getValue('user_quota_max_size'));
+ // Finally convert the value to KiB.
+ result['max_size_kb'] = (bytes / 1024).toFixed(0) as any;
+ }
+ if (!this.userForm.getValue('user_quota_max_objects_unlimited')) {
+ result['max_objects'] = this.userForm.getValue('user_quota_max_objects');
+ }
+ return result;
+ }
+
+ /**
+ * Helper function to get the arguments for the API request when the bucket
+ * quota configuration has been modified.
+ */
+ private _getBucketQuotaArgs(): Record<string, any> {
+ const result = {
+ quota_type: 'bucket',
+ enabled: this.userForm.getValue('bucket_quota_enabled'),
+ max_size_kb: -1,
+ max_objects: -1
+ };
+ if (!this.userForm.getValue('bucket_quota_max_size_unlimited')) {
+ // Convert the given value to bytes.
+ const bytes = new FormatterService().toBytes(this.userForm.getValue('bucket_quota_max_size'));
+ // Finally convert the value to KiB.
+ result['max_size_kb'] = (bytes / 1024).toFixed(0) as any;
+ }
+ if (!this.userForm.getValue('bucket_quota_max_objects_unlimited')) {
+ result['max_objects'] = this.userForm.getValue('bucket_quota_max_objects');
+ }
+ return result;
+ }
+
+ /**
+ * Helper method to get the user candidates for S3 keys.
+ * @returns {Array} Returns a list of user identifiers.
+ */
+ private _getS3KeyUserCandidates() {
+ let result = [];
+ // Add the current user id.
+ const uid = this.getUID();
+ if (_.isString(uid) && !_.isEmpty(uid)) {
+ result.push(uid);
+ }
+ // Append the subusers.
+ this.subusers.forEach((subUser) => {
+ result.push(subUser.id);
+ });
+ // Note that it's possible to create multiple S3 key pairs for a user,
+ // thus we append already configured users, too.
+ this.s3Keys.forEach((key) => {
+ result.push(key.user);
+ });
+ result = _.uniq(result);
+ return result;
+ }
+
+ onMaxBucketsModeChange(mode: string) {
+ if (mode === '1') {
+ // If 'Custom' mode is selected, then ensure that the form field
+ // 'Max. buckets' contains a valid value. Set it to default if
+ // necessary.
+ if (!this.userForm.get('max_buckets').valid) {
+ this.userForm.patchValue({
+ max_buckets: 1000
+ });
+ }
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html
new file mode 100644
index 000000000..6c6d7677e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html
@@ -0,0 +1,44 @@
+<cd-table #table
+ [autoReload]="false"
+ [data]="users"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="multiClick"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)"
+ identifier="uid"
+ (fetchData)="getUserList($event)"
+ [status]="tableStatus">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-rgw-user-details cdTableDetail
+ [selection]="expandedRow">
+ </cd-rgw-user-details>
+</cd-table>
+
+<ng-template #userSizeTpl
+ let-row="row">
+ <cd-usage-bar *ngIf="row.user_quota.max_size > 0 && row.user_quota.enabled; else noSizeQuota"
+ [total]="row.user_quota.max_size"
+ [used]="row.stats.size_actual">
+ </cd-usage-bar>
+
+ <ng-template #noSizeQuota
+ i18n>No Limit</ng-template>
+</ng-template>
+
+<ng-template #userObjectTpl
+ let-row="row">
+ <cd-usage-bar *ngIf="row.user_quota.max_objects > 0 && row.user_quota.enabled; else noObjectQuota"
+ [total]="row.user_quota.max_objects"
+ [used]="row.stats.num_objects"
+ [isBinary]="false">
+ </cd-usage-bar>
+
+ <ng-template #noObjectQuota
+ i18n>No Limit</ng-template>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.spec.ts
new file mode 100644
index 000000000..2f886ccf5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.spec.ts
@@ -0,0 +1,166 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { RgwUserListComponent } from './rgw-user-list.component';
+
+describe('RgwUserListComponent', () => {
+ let component: RgwUserListComponent;
+ let fixture: ComponentFixture<RgwUserListComponent>;
+ let rgwUserService: RgwUserService;
+ let rgwUserServiceListSpy: jasmine.Spy;
+
+ configureTestBed({
+ declarations: [RgwUserListComponent],
+ imports: [BrowserAnimationsModule, RouterTestingModule, HttpClientTestingModule, SharedModule],
+ schemas: [NO_ERRORS_SCHEMA]
+ });
+
+ beforeEach(() => {
+ rgwUserService = TestBed.inject(RgwUserService);
+ rgwUserServiceListSpy = spyOn(rgwUserService, 'list');
+ rgwUserServiceListSpy.and.returnValue(of([]));
+ fixture = TestBed.createComponent(RgwUserListComponent);
+ component = fixture.componentInstance;
+ spyOn(component, 'setTableRefreshTimeout').and.stub();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(rgwUserServiceListSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Edit', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ it('should test if rgw-user data is tranformed correctly', () => {
+ rgwUserServiceListSpy.and.returnValue(
+ of([
+ {
+ user_id: 'testid',
+ stats: {
+ size_actual: 6,
+ num_objects: 6
+ },
+ user_quota: {
+ max_size: 20,
+ max_objects: 10,
+ enabled: true
+ }
+ }
+ ])
+ );
+ component.getUserList(null);
+ expect(rgwUserServiceListSpy).toHaveBeenCalledTimes(2);
+ expect(component.users).toEqual([
+ {
+ user_id: 'testid',
+ stats: {
+ size_actual: 6,
+ num_objects: 6
+ },
+ user_quota: {
+ max_size: 20,
+ max_objects: 10,
+ enabled: true
+ }
+ }
+ ]);
+ });
+
+ it('should usage bars only if quota enabled', () => {
+ rgwUserServiceListSpy.and.returnValue(
+ of([
+ {
+ user_id: 'testid',
+ stats: {
+ size_actual: 6,
+ num_objects: 6
+ },
+ user_quota: {
+ max_size: 1024,
+ max_objects: 10,
+ enabled: true
+ }
+ }
+ ])
+ );
+ component.getUserList(null);
+ expect(rgwUserServiceListSpy).toHaveBeenCalledTimes(2);
+ fixture.detectChanges();
+ const usageBars = fixture.debugElement.nativeElement.querySelectorAll('cd-usage-bar');
+ expect(usageBars.length).toBe(2);
+ });
+
+ it('should not show any usage bars if quota disabled', () => {
+ rgwUserServiceListSpy.and.returnValue(
+ of([
+ {
+ user_id: 'testid',
+ stats: {
+ size_actual: 6,
+ num_objects: 6
+ },
+ user_quota: {
+ max_size: 1024,
+ max_objects: 10,
+ enabled: false
+ }
+ }
+ ])
+ );
+ component.getUserList(null);
+ expect(rgwUserServiceListSpy).toHaveBeenCalledTimes(2);
+ fixture.detectChanges();
+ const usageBars = fixture.debugElement.nativeElement.querySelectorAll('cd-usage-bar');
+ expect(usageBars.length).toBe(0);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
new file mode 100644
index 000000000..34cccb940
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
@@ -0,0 +1,180 @@
+import { Component, NgZone, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { forkJoin as observableForkJoin, Observable, Subscriber } from 'rxjs';
+
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } 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';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+
+const BASE_URL = 'rgw/user';
+
+@Component({
+ selector: 'cd-rgw-user-list',
+ templateUrl: './rgw-user-list.component.html',
+ styleUrls: ['./rgw-user-list.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class RgwUserListComponent extends ListWithDetails implements OnInit {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+ @ViewChild('userSizeTpl', { static: true })
+ userSizeTpl: TemplateRef<any>;
+ @ViewChild('userObjectTpl', { static: true })
+ userObjectTpl: TemplateRef<any>;
+ permission: Permission;
+ tableActions: CdTableAction[];
+ columns: CdTableColumn[] = [];
+ users: object[] = [];
+ selection: CdTableSelection = new CdTableSelection();
+ staleTimeout: number;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rgwUserService: RgwUserService,
+ private modalService: ModalService,
+ private urlBuilder: URLBuilderService,
+ public actionLabels: ActionLabelsI18n,
+ protected ngZone: NgZone
+ ) {
+ super(ngZone);
+ }
+
+ ngOnInit() {
+ this.permission = this.authStorageService.getPermissions().rgw;
+ this.columns = [
+ {
+ name: $localize`Username`,
+ prop: 'uid',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Tenant`,
+ prop: 'tenant',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Full name`,
+ prop: 'display_name',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Email address`,
+ prop: 'email',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Suspended`,
+ prop: 'suspended',
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ name: $localize`Max. buckets`,
+ prop: 'max_buckets',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.map,
+ customTemplateConfig: {
+ '-1': $localize`Disabled`,
+ 0: $localize`Unlimited`
+ }
+ },
+ {
+ name: $localize`Capacity Limit %`,
+ prop: 'size_usage',
+ cellTemplate: this.userSizeTpl,
+ flexGrow: 0.8
+ },
+ {
+ name: $localize`Object Limit %`,
+ prop: 'object_usage',
+ cellTemplate: this.userObjectTpl,
+ flexGrow: 0.8
+ }
+ ];
+ const getUserUri = () =>
+ this.selection.first() && `${encodeURIComponent(this.selection.first().uid)}`;
+ const addAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ name: this.actionLabels.CREATE,
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ };
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () => this.urlBuilder.getEdit(getUserUri()),
+ name: this.actionLabels.EDIT
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteAction(),
+ disable: () => !this.selection.hasSelection,
+ name: this.actionLabels.DELETE,
+ canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ };
+ this.tableActions = [addAction, editAction, deleteAction];
+ this.setTableRefreshTimeout();
+ }
+
+ getUserList(context: CdTableFetchDataContext) {
+ this.setTableRefreshTimeout();
+ this.rgwUserService.list().subscribe(
+ (resp: object[]) => {
+ this.users = resp;
+ },
+ () => {
+ context.error();
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteAction() {
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: this.selection.hasSingleSelection ? $localize`user` : $localize`users`,
+ itemNames: this.selection.selected.map((user: any) => user['uid']),
+ submitActionObservable: (): Observable<any> => {
+ return new Observable((observer: Subscriber<any>) => {
+ // Delete all selected data table rows.
+ observableForkJoin(
+ this.selection.selected.map((user: any) => {
+ return this.rgwUserService.delete(user.uid);
+ })
+ ).subscribe({
+ error: (error) => {
+ // Forward the error to the observer.
+ observer.error(error);
+ // Reload the data table content because some deletions might
+ // have been executed successfully in the meanwhile.
+ this.table.refreshBtn();
+ },
+ complete: () => {
+ // Notify the observer that we are done.
+ observer.complete();
+ // Reload the data table content.
+ this.table.refreshBtn();
+ }
+ });
+ });
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html
new file mode 100644
index 000000000..6ec8978af
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html
@@ -0,0 +1,125 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+
+ <!-- Username -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !viewing}"
+ for="user"
+ i18n>Username</label>
+ <div class="cd-col-form-input">
+ <input id="user"
+ class="form-control"
+ type="text"
+ *ngIf="viewing"
+ [readonly]="true"
+ formControlName="user">
+ <select id="user"
+ class="form-control"
+ formControlName="user"
+ *ngIf="!viewing"
+ autofocus>
+ <option i18n
+ *ngIf="userCandidates !== null"
+ [ngValue]="null">-- Select a username --</option>
+ <option *ngFor="let userCandidate of userCandidates"
+ [value]="userCandidate">{{ userCandidate }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('user', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Auto-generate key -->
+ <div class="form-group row"
+ *ngIf="!viewing">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="generate_key"
+ type="checkbox"
+ formControlName="generate_key">
+ <label class="custom-control-label"
+ for="generate_key"
+ i18n>Auto-generate key</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Access key -->
+ <div class="form-group row"
+ *ngIf="!formGroup.getValue('generate_key')">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !viewing}"
+ for="access_key"
+ i18n>Access key</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="access_key"
+ class="form-control"
+ type="password"
+ [readonly]="viewing"
+ formControlName="access_key">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="access_key">
+ </button>
+ <cd-copy-2-clipboard-button source="access_key">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('access_key', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Secret key -->
+ <div class="form-group row"
+ *ngIf="!formGroup.getValue('generate_key')">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !viewing}"
+ for="secret_key"
+ i18n>Secret key</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="secret_key"
+ class="form-control"
+ type="password"
+ [readonly]="viewing"
+ formControlName="secret_key">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="secret_key">
+ </button>
+ <cd-copy-2-clipboard-button source="secret_key">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('secret_key', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="formGroup"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ [showSubmit]="!viewing"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts
new file mode 100644
index 000000000..b6152c59f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwUserS3KeyModalComponent } from './rgw-user-s3-key-modal.component';
+
+describe('RgwUserS3KeyModalComponent', () => {
+ let component: RgwUserS3KeyModalComponent;
+ let fixture: ComponentFixture<RgwUserS3KeyModalComponent>;
+
+ configureTestBed({
+ declarations: [RgwUserS3KeyModalComponent],
+ imports: [ReactiveFormsModule, SharedModule, RouterTestingModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwUserS3KeyModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts
new file mode 100644
index 000000000..23566e87c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts
@@ -0,0 +1,84 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+
+@Component({
+ selector: 'cd-rgw-user-s3-key-modal',
+ templateUrl: './rgw-user-s3-key-modal.component.html',
+ styleUrls: ['./rgw-user-s3-key-modal.component.scss']
+})
+export class RgwUserS3KeyModalComponent {
+ /**
+ * The event that is triggered when the 'Add' button as been pressed.
+ */
+ @Output()
+ submitAction = new EventEmitter();
+
+ formGroup: CdFormGroup;
+ viewing = true;
+ userCandidates: string[] = [];
+ resource: string;
+ action: string;
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.resource = $localize`S3 Key`;
+ this.createForm();
+ }
+
+ createForm() {
+ this.formGroup = this.formBuilder.group({
+ user: [null, [Validators.required]],
+ generate_key: [true],
+ access_key: [null, [CdValidators.requiredIf({ generate_key: false })]],
+ secret_key: [null, [CdValidators.requiredIf({ generate_key: false })]]
+ });
+ }
+
+ /**
+ * Set the 'viewing' flag. If set to TRUE, the modal dialog is in 'View' mode,
+ * otherwise in 'Add' mode. According to the mode the dialog and its controls
+ * behave different.
+ * @param {boolean} viewing
+ */
+ setViewing(viewing: boolean = true) {
+ this.viewing = viewing;
+ this.action = this.viewing ? this.actionLabels.SHOW : this.actionLabels.CREATE;
+ }
+
+ /**
+ * Set the values displayed in the dialog.
+ */
+ setValues(user: string, access_key: string, secret_key: string) {
+ this.formGroup.setValue({
+ user: user,
+ generate_key: _.isEmpty(access_key),
+ access_key: access_key,
+ secret_key: secret_key
+ });
+ }
+
+ /**
+ * Set the user candidates displayed in the 'Username' dropdown box.
+ */
+ setUserCandidates(candidates: string[]) {
+ this.userCandidates = candidates;
+ }
+
+ onSubmit() {
+ const key: RgwUserS3Key = this.formGroup.value;
+ this.submitAction.emit(key);
+ this.activeModal.close();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html
new file mode 100644
index 000000000..4fb03f019
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html
@@ -0,0 +1,128 @@
+<cd-modal [modalRef]="bsModalRef">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+
+ <!-- Username -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="uid"
+ i18n>Username</label>
+ <div class="cd-col-form-input">
+ <input id="uid"
+ class="form-control"
+ type="text"
+ formControlName="uid"
+ [readonly]="true">
+ </div>
+ </div>
+
+ <!-- Subuser -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': !editing}"
+ for="subuid"
+ i18n>Subuser</label>
+ <div class="cd-col-form-input">
+ <input id="subuid"
+ class="form-control"
+ type="text"
+ formControlName="subuid"
+ [readonly]="editing"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('subuid', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('subuid', frm, 'subuserIdExists')"
+ i18n>The chosen subuser ID is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Permission -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="perm"
+ i18n>Permission</label>
+ <div class="cd-col-form-input">
+ <select id="perm"
+ class="form-control"
+ formControlName="perm">
+ <option i18n
+ [ngValue]="null">-- Select a permission --</option>
+ <option *ngFor="let perm of ['read', 'write']"
+ [value]="perm">
+ {{ perm }}
+ </option>
+ <option i18n
+ value="read-write">read, write</option>
+ <option i18n
+ value="full-control">full</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('perm', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Swift key -->
+ <fieldset *ngIf="!editing">
+ <legend i18n>Swift key</legend>
+
+ <!-- Auto-generate key -->
+ <div class="form-group row">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="generate_secret"
+ type="checkbox"
+ formControlName="generate_secret">
+ <label class="custom-control-label"
+ for="generate_secret"
+ i18n>Auto-generate secret</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Secret key -->
+ <div class="form-group row"
+ *ngIf="!editing && !formGroup.getValue('generate_secret')">
+ <label class="cd-col-form-label required"
+ for="secret_key"
+ i18n>Secret key</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="secret_key"
+ class="form-control"
+ type="password"
+ formControlName="secret_key">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="secret_key">
+ </button>
+ <cd-copy-2-clipboard-button source="secret_key">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="formGroup.showError('secret_key', frm, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ </fieldset>
+
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="formGroup"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts
new file mode 100644
index 000000000..d4843aa9d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts
@@ -0,0 +1,71 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwUserSubuserModalComponent } from './rgw-user-subuser-modal.component';
+
+describe('RgwUserSubuserModalComponent', () => {
+ let component: RgwUserSubuserModalComponent;
+ let fixture: ComponentFixture<RgwUserSubuserModalComponent>;
+
+ configureTestBed({
+ declarations: [RgwUserSubuserModalComponent],
+ imports: [ReactiveFormsModule, SharedModule, RouterTestingModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwUserSubuserModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('subuserValidator', () => {
+ beforeEach(() => {
+ component.editing = false;
+ component.subusers = [
+ { id: 'Edith', permissions: 'full-control' },
+ { id: 'Edith:images', permissions: 'read-write' }
+ ];
+ });
+
+ it('should validate subuser (1/5)', () => {
+ component.editing = true;
+ const validatorFn = component.subuserValidator();
+ const resp = validatorFn(new FormControl());
+ expect(resp).toBe(null);
+ });
+
+ it('should validate subuser (2/5)', () => {
+ const validatorFn = component.subuserValidator();
+ const resp = validatorFn(new FormControl(''));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate subuser (3/5)', () => {
+ const validatorFn = component.subuserValidator();
+ const resp = validatorFn(new FormControl('Melissa'));
+ expect(resp).toBe(null);
+ });
+
+ it('should validate subuser (4/5)', () => {
+ const validatorFn = component.subuserValidator();
+ const resp = validatorFn(new FormControl('Edith'));
+ expect(resp.subuserIdExists).toBeTruthy();
+ });
+
+ it('should validate subuser (5/5)', () => {
+ const validatorFn = component.subuserValidator();
+ const resp = validatorFn(new FormControl('images'));
+ expect(resp.subuserIdExists).toBeTruthy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts
new file mode 100644
index 000000000..32aef91fe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts
@@ -0,0 +1,130 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators, isEmptyInputValue } from '~/app/shared/forms/cd-validators';
+import { RgwUserSubuser } from '../models/rgw-user-subuser';
+
+@Component({
+ selector: 'cd-rgw-user-subuser-modal',
+ templateUrl: './rgw-user-subuser-modal.component.html',
+ styleUrls: ['./rgw-user-subuser-modal.component.scss']
+})
+export class RgwUserSubuserModalComponent {
+ /**
+ * The event that is triggered when the 'Add' or 'Update' button
+ * has been pressed.
+ */
+ @Output()
+ submitAction = new EventEmitter();
+
+ formGroup: CdFormGroup;
+ editing = true;
+ subusers: RgwUserSubuser[] = [];
+ resource: string;
+ action: string;
+
+ constructor(
+ private formBuilder: CdFormBuilder,
+ public bsModalRef: NgbActiveModal,
+ private actionLabels: ActionLabelsI18n
+ ) {
+ this.resource = $localize`Subuser`;
+ this.createForm();
+ }
+
+ createForm() {
+ this.formGroup = this.formBuilder.group({
+ uid: [null],
+ subuid: [null, [Validators.required, this.subuserValidator()]],
+ perm: [null, [Validators.required]],
+ // Swift key
+ generate_secret: [true],
+ secret_key: [null, [CdValidators.requiredIf({ generate_secret: false })]]
+ });
+ }
+
+ /**
+ * Validates whether the subuser already exists.
+ */
+ subuserValidator(): ValidatorFn {
+ const self = this;
+ return (control: AbstractControl): ValidationErrors | null => {
+ if (self.editing) {
+ return null;
+ }
+ if (isEmptyInputValue(control.value)) {
+ return null;
+ }
+ const found = self.subusers.some((subuser) => {
+ return _.isEqual(self.getSubuserName(subuser.id), control.value);
+ });
+ return found ? { subuserIdExists: true } : null;
+ };
+ }
+
+ /**
+ * Get the subuser name.
+ * Examples:
+ * 'johndoe' => 'johndoe'
+ * 'janedoe:xyz' => 'xyz'
+ * @param {string} value The value to process.
+ * @returns {string} Returns the user ID.
+ */
+ private getSubuserName(value: string) {
+ if (_.isEmpty(value)) {
+ return value;
+ }
+ const matches = value.match(/([^:]+)(:(.+))?/);
+ return _.isUndefined(matches[3]) ? matches[1] : matches[3];
+ }
+
+ /**
+ * Set the 'editing' flag. If set to TRUE, the modal dialog is in 'Edit' mode,
+ * otherwise in 'Add' mode. According to the mode the dialog and its controls
+ * behave different.
+ * @param {boolean} viewing
+ */
+ setEditing(editing: boolean = true) {
+ this.editing = editing;
+ this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
+ }
+
+ /**
+ * Set the values displayed in the dialog.
+ */
+ setValues(uid: string, subuser: string = '', permissions: string = '') {
+ this.formGroup.setValue({
+ uid: uid,
+ subuid: this.getSubuserName(subuser),
+ perm: permissions,
+ generate_secret: true,
+ secret_key: null
+ });
+ }
+
+ /**
+ * Set the current capabilities of the user.
+ */
+ setSubusers(subusers: RgwUserSubuser[]) {
+ this.subusers = subusers;
+ }
+
+ onSubmit() {
+ // Get the values from the form and create an object that is sent
+ // by the triggered submit action event.
+ const values = this.formGroup.value;
+ const subuser = new RgwUserSubuser();
+ subuser.id = `${values.uid}:${values.subuid}`;
+ subuser.permissions = values.perm;
+ subuser.generate_secret = values.generate_secret;
+ subuser.secret_key = values.secret_key;
+ this.submitAction.emit(subuser);
+ this.bsModalRef.close();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html
new file mode 100644
index 000000000..00f3cf0f2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html
@@ -0,0 +1,54 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+
+ <ng-container class="modal-content">
+ <div class="modal-body">
+ <form novalidate>
+ <!-- Username -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="user"
+ i18n>Username</label>
+ <div class="cd-col-form-input">
+ <input id="user"
+ name="user"
+ class="form-control"
+ type="text"
+ [readonly]="true"
+ [(ngModel)]="user">
+ </div>
+ </div>
+
+ <!-- Secret key -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="secret_key"
+ i18n>Secret key</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="secret_key"
+ name="secret_key"
+ class="form-control"
+ type="password"
+ [(ngModel)]="secret_key"
+ [readonly]="true">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="secret_key">
+ </button>
+ <cd-copy-2-clipboard-button source="secret_key">
+ </cd-copy-2-clipboard-button>
+ </span>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <div class="modal-footer">
+ <cd-back-button (backAction)="activeModal.close()"></cd-back-button>
+ </div>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts
new file mode 100644
index 000000000..f7ecf3290
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts
@@ -0,0 +1,31 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal.component';
+
+describe('RgwUserSwiftKeyModalComponent', () => {
+ let component: RgwUserSwiftKeyModalComponent;
+ let fixture: ComponentFixture<RgwUserSwiftKeyModalComponent>;
+
+ configureTestBed({
+ declarations: [RgwUserSwiftKeyModalComponent],
+ imports: [ToastrModule.forRoot(), FormsModule, SharedModule, RouterTestingModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RgwUserSwiftKeyModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts
new file mode 100644
index 000000000..7bd63bcc0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts
@@ -0,0 +1,30 @@
+import { Component } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+
+@Component({
+ selector: 'cd-rgw-user-swift-key-modal',
+ templateUrl: './rgw-user-swift-key-modal.component.html',
+ styleUrls: ['./rgw-user-swift-key-modal.component.scss']
+})
+export class RgwUserSwiftKeyModalComponent {
+ user: string;
+ secret_key: string;
+ resource: string;
+ action: string;
+
+ constructor(public activeModal: NgbActiveModal, public actionLabels: ActionLabelsI18n) {
+ this.resource = $localize`Swift Key`;
+ this.action = this.actionLabels.SHOW;
+ }
+
+ /**
+ * Set the values displayed in the dialog.
+ */
+ setValues(user: string, secret_key: string) {
+ this.user = user;
+ this.secret_key = secret_key;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
new file mode 100644
index 000000000..4abcd6979
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
@@ -0,0 +1,108 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule, Routes } from '@angular/router';
+
+import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants';
+import { SharedModule } from '~/app/shared/shared.module';
+import { PerformanceCounterModule } from '../performance-counter/performance-counter.module';
+import { RgwBucketDetailsComponent } from './rgw-bucket-details/rgw-bucket-details.component';
+import { RgwBucketFormComponent } from './rgw-bucket-form/rgw-bucket-form.component';
+import { RgwBucketListComponent } from './rgw-bucket-list/rgw-bucket-list.component';
+import { RgwDaemonDetailsComponent } from './rgw-daemon-details/rgw-daemon-details.component';
+import { RgwDaemonListComponent } from './rgw-daemon-list/rgw-daemon-list.component';
+import { RgwUserCapabilityModalComponent } from './rgw-user-capability-modal/rgw-user-capability-modal.component';
+import { RgwUserDetailsComponent } from './rgw-user-details/rgw-user-details.component';
+import { RgwUserFormComponent } from './rgw-user-form/rgw-user-form.component';
+import { RgwUserListComponent } from './rgw-user-list/rgw-user-list.component';
+import { RgwUserS3KeyModalComponent } from './rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
+import { RgwUserSubuserModalComponent } from './rgw-user-subuser-modal/rgw-user-subuser-modal.component';
+import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ SharedModule,
+ FormsModule,
+ ReactiveFormsModule,
+ PerformanceCounterModule,
+ NgbNavModule,
+ RouterModule,
+ NgbTooltipModule,
+ NgxPipeFunctionModule
+ ],
+ exports: [
+ RgwDaemonListComponent,
+ RgwDaemonDetailsComponent,
+ RgwBucketFormComponent,
+ RgwBucketListComponent,
+ RgwBucketDetailsComponent,
+ RgwUserListComponent,
+ RgwUserDetailsComponent
+ ],
+ declarations: [
+ RgwDaemonListComponent,
+ RgwDaemonDetailsComponent,
+ RgwBucketFormComponent,
+ RgwBucketListComponent,
+ RgwBucketDetailsComponent,
+ RgwUserListComponent,
+ RgwUserDetailsComponent,
+ RgwBucketFormComponent,
+ RgwUserFormComponent,
+ RgwUserSwiftKeyModalComponent,
+ RgwUserS3KeyModalComponent,
+ RgwUserCapabilityModalComponent,
+ RgwUserSubuserModalComponent
+ ]
+})
+export class RgwModule {}
+
+const routes: Routes = [
+ {
+ path: '' // Required for a clean reload on daemon selection.
+ },
+ { path: 'daemon', component: RgwDaemonListComponent, data: { breadcrumbs: 'Daemons' } },
+ {
+ path: 'user',
+ data: { breadcrumbs: 'Users' },
+ children: [
+ { path: '', component: RgwUserListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: RgwUserFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:uid`,
+ component: RgwUserFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ },
+ {
+ path: 'bucket',
+ data: { breadcrumbs: 'Buckets' },
+ children: [
+ { path: '', component: RgwBucketListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: RgwBucketFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:bid`,
+ component: RgwBucketFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ }
+];
+
+@NgModule({
+ imports: [RgwModule, RouterModule.forChild(routes)]
+})
+export class RoutedRgwModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts
new file mode 100644
index 000000000..9e9f2917a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts
@@ -0,0 +1,17 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { DataTableModule } from '~/app/shared/datatable/datatable.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { DeviceListComponent } from './device-list/device-list.component';
+import { SmartListComponent } from './smart-list/smart-list.component';
+
+@NgModule({
+ imports: [CommonModule, DataTableModule, SharedModule, NgbNavModule, NgxPipeFunctionModule],
+ exports: [DeviceListComponent, SmartListComponent],
+ declarations: [DeviceListComponent, SmartListComponent]
+})
+export class CephSharedModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html
new file mode 100644
index 000000000..56fbb965a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html
@@ -0,0 +1,26 @@
+<cd-table *ngIf="hostname || osdId !== null"
+ [data]="devices"
+ [columns]="columns"></cd-table>
+
+<cd-alert-panel type="warning"
+ *ngIf="hostname === '' && osdId === null"
+ i18n>Neither hostname nor OSD ID given</cd-alert-panel>
+
+<ng-template #deviceLocation
+ let-value="value">
+ <span *ngFor="let location of value">{{location.dev}}</span>
+</ng-template>
+
+<ng-template #lifeExpectancy
+ let-value="value">
+ <span *ngIf="!value.life_expectancy_enabled"
+ i18n>{{ "" | notAvailable }}</span>
+ <span *ngIf="value.min && !value.max">&gt; {{value.min | i18nPlural: translationMapping}}</span>
+ <span *ngIf="value.max && !value.min">&lt; {{value.max | i18nPlural: translationMapping}}</span>
+ <span *ngIf="value.max && value.min">{{value.min}} to {{value.max | i18nPlural: translationMapping}}</span>
+</ng-template>
+
+<ng-template #lifeExpectancyTimestamp
+ let-value="value">
+ {{value}}
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts
new file mode 100644
index 000000000..718d04727
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts
@@ -0,0 +1,26 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DeviceListComponent } from './device-list.component';
+
+describe('DeviceListComponent', () => {
+ let component: DeviceListComponent;
+ let fixture: ComponentFixture<DeviceListComponent>;
+
+ configureTestBed({
+ declarations: [DeviceListComponent],
+ imports: [SharedModule, HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DeviceListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts
new file mode 100644
index 000000000..5503d1319
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts
@@ -0,0 +1,84 @@
+import { DatePipe } from '@angular/common';
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdDevice } from '~/app/shared/models/devices';
+
+@Component({
+ selector: 'cd-device-list',
+ templateUrl: './device-list.component.html',
+ styleUrls: ['./device-list.component.scss']
+})
+export class DeviceListComponent implements OnChanges, OnInit {
+ @Input()
+ hostname = '';
+ @Input()
+ osdId: number = null;
+
+ @ViewChild('deviceLocation', { static: true })
+ locationTemplate: TemplateRef<any>;
+ @ViewChild('lifeExpectancy', { static: true })
+ lifeExpectancyTemplate: TemplateRef<any>;
+ @ViewChild('lifeExpectancyTimestamp', { static: true })
+ lifeExpectancyTimestampTemplate: TemplateRef<any>;
+
+ devices: CdDevice[] = null;
+ columns: CdTableColumn[] = [];
+ translationMapping = {
+ '=1': '# week',
+ other: '# weeks'
+ };
+
+ constructor(
+ private hostService: HostService,
+ private datePipe: DatePipe,
+ private osdService: OsdService
+ ) {}
+
+ ngOnInit() {
+ this.columns = [
+ { prop: 'devid', name: $localize`Device ID`, minWidth: 200 },
+ {
+ prop: 'state',
+ name: $localize`State of Health`,
+ flexGrow: 1,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ map: {
+ good: { value: $localize`Good`, class: 'badge-success' },
+ warning: { value: $localize`Warning`, class: 'badge-warning' },
+ bad: { value: $localize`Bad`, class: 'badge-danger' },
+ stale: { value: $localize`Stale`, class: 'badge-info' },
+ unknown: { value: $localize`Unknown`, class: 'badge-dark' }
+ }
+ }
+ },
+ {
+ prop: 'life_expectancy_weeks',
+ name: $localize`Life Expectancy`,
+ cellTemplate: this.lifeExpectancyTemplate
+ },
+ {
+ prop: 'life_expectancy_stamp',
+ name: $localize`Prediction Creation Date`,
+ cellTemplate: this.lifeExpectancyTimestampTemplate,
+ pipe: this.datePipe,
+ isHidden: true
+ },
+ { prop: 'location', name: $localize`Device Name`, cellTemplate: this.locationTemplate },
+ { prop: 'readableDaemons', name: $localize`Daemons` }
+ ];
+ }
+
+ ngOnChanges() {
+ const updateDevicesFn = (devices: CdDevice[]) => (this.devices = devices);
+ if (this.hostname) {
+ this.hostService.getDevices(this.hostname).subscribe(updateDevicesFn);
+ } else if (this.osdId !== null) {
+ this.osdService.getDevices(this.osdId).subscribe(updateDevicesFn);
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts
new file mode 100644
index 000000000..12fda7784
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts
@@ -0,0 +1,71 @@
+export class PgCategory {
+ static readonly CATEGORY_CLEAN = 'clean';
+ static readonly CATEGORY_WORKING = 'working';
+ static readonly CATEGORY_WARNING = 'warning';
+ static readonly CATEGORY_UNKNOWN = 'unknown';
+ static readonly VALID_CATEGORIES = [
+ PgCategory.CATEGORY_CLEAN,
+ PgCategory.CATEGORY_WORKING,
+ PgCategory.CATEGORY_WARNING,
+ PgCategory.CATEGORY_UNKNOWN
+ ];
+
+ states: string[];
+
+ constructor(public type: string) {
+ if (!this.isValidType()) {
+ throw new Error('Wrong placement group category type');
+ }
+
+ this.setTypeStates();
+ }
+
+ private isValidType() {
+ return PgCategory.VALID_CATEGORIES.includes(this.type);
+ }
+
+ private setTypeStates() {
+ switch (this.type) {
+ case PgCategory.CATEGORY_CLEAN:
+ this.states = ['active', 'clean'];
+ break;
+ case PgCategory.CATEGORY_WORKING:
+ this.states = [
+ 'activating',
+ 'backfill_wait',
+ 'backfilling',
+ 'creating',
+ 'deep',
+ 'degraded',
+ 'forced_backfill',
+ 'forced_recovery',
+ 'peering',
+ 'peered',
+ 'recovering',
+ 'recovery_wait',
+ 'repair',
+ 'scrubbing',
+ 'snaptrim',
+ 'snaptrim_wait'
+ ];
+ break;
+ case PgCategory.CATEGORY_WARNING:
+ this.states = [
+ 'backfill_toofull',
+ 'backfill_unfound',
+ 'down',
+ 'incomplete',
+ 'inconsistent',
+ 'recovery_toofull',
+ 'recovery_unfound',
+ 'remapped',
+ 'snaptrim_error',
+ 'stale',
+ 'undersized'
+ ];
+ break;
+ default:
+ this.states = [];
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts
new file mode 100644
index 000000000..2b3e2975c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts
@@ -0,0 +1,56 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { PgCategory } from './pg-category.model';
+import { PgCategoryService } from './pg-category.service';
+
+describe('PgCategoryService', () => {
+ let service: PgCategoryService;
+
+ configureTestBed({
+ providers: [PgCategoryService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(PgCategoryService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('returns all category types', () => {
+ const categoryTypes = service.getAllTypes();
+
+ expect(categoryTypes).toEqual(PgCategory.VALID_CATEGORIES);
+ });
+
+ describe('getTypeByStates', () => {
+ const testMethod = (value: string, expected: string) =>
+ expect(service.getTypeByStates(value)).toEqual(expected);
+
+ it(PgCategory.CATEGORY_CLEAN, () => {
+ testMethod('clean', PgCategory.CATEGORY_CLEAN);
+ });
+
+ it(PgCategory.CATEGORY_WORKING, () => {
+ testMethod('clean+scrubbing', PgCategory.CATEGORY_WORKING);
+ testMethod('active+clean+snaptrim_wait', PgCategory.CATEGORY_WORKING);
+ testMethod(
+ ' 8 active+clean+scrubbing+deep, 255 active+clean ',
+ PgCategory.CATEGORY_WORKING
+ );
+ });
+
+ it(PgCategory.CATEGORY_WARNING, () => {
+ testMethod('clean+scrubbing+down', PgCategory.CATEGORY_WARNING);
+ testMethod('clean+scrubbing+down+nonMappedState', PgCategory.CATEGORY_WARNING);
+ });
+
+ it(PgCategory.CATEGORY_UNKNOWN, () => {
+ testMethod('clean+scrubbing+nonMappedState', PgCategory.CATEGORY_UNKNOWN);
+ testMethod('nonMappedState', PgCategory.CATEGORY_UNKNOWN);
+ testMethod('', PgCategory.CATEGORY_UNKNOWN);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts
new file mode 100644
index 000000000..ae178ded2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts
@@ -0,0 +1,63 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+import { CephSharedModule } from './ceph-shared.module';
+import { PgCategory } from './pg-category.model';
+
+@Injectable({
+ providedIn: CephSharedModule
+})
+export class PgCategoryService {
+ private categories: object;
+
+ constructor() {
+ this.categories = this.createCategories();
+ }
+
+ getAllTypes() {
+ return PgCategory.VALID_CATEGORIES;
+ }
+
+ getTypeByStates(pgStatesText: string): string {
+ const pgStates = this.getPgStatesFromText(pgStatesText);
+
+ if (pgStates.length === 0) {
+ return PgCategory.CATEGORY_UNKNOWN;
+ }
+
+ const intersections = _.zipObject(
+ PgCategory.VALID_CATEGORIES,
+ PgCategory.VALID_CATEGORIES.map(
+ (category) => _.intersection(this.categories[category].states, pgStates).length
+ )
+ );
+
+ if (intersections[PgCategory.CATEGORY_WARNING] > 0) {
+ return PgCategory.CATEGORY_WARNING;
+ }
+
+ const pgWorkingStates = intersections[PgCategory.CATEGORY_WORKING];
+ if (pgStates.length > intersections[PgCategory.CATEGORY_CLEAN] + pgWorkingStates) {
+ return PgCategory.CATEGORY_UNKNOWN;
+ }
+
+ return pgWorkingStates ? PgCategory.CATEGORY_WORKING : PgCategory.CATEGORY_CLEAN;
+ }
+
+ private createCategories() {
+ return _.zipObject(
+ PgCategory.VALID_CATEGORIES,
+ PgCategory.VALID_CATEGORIES.map((category) => new PgCategory(category))
+ );
+ }
+
+ private getPgStatesFromText(pgStatesText: string) {
+ const pgStates = pgStatesText
+ .replace(/[^a-z_]+/g, ' ')
+ .trim()
+ .split(' ');
+
+ return _.uniq(pgStates);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_ata_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_ata_response.json
new file mode 100644
index 000000000..514c2966c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_ata_response.json
@@ -0,0 +1,570 @@
+{
+ "WDC_WD1003FBYX-01Y7B1_WD-WCAW11111111": {
+ "ata_sct_capabilities": {
+ "data_table_supported": true,
+ "error_recovery_control_supported": true,
+ "feature_control_supported": true,
+ "value": 12351
+ },
+ "ata_smart_attributes": {
+ "revision": 16,
+ "table": [
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": true,
+ "event_count": false,
+ "performance": true,
+ "prefailure": true,
+ "string": "POSR-K ",
+ "updated_online": true,
+ "value": 47
+ },
+ "id": 1,
+ "name": "Raw_Read_Error_Rate",
+ "raw": {
+ "string": "1",
+ "value": 1
+ },
+ "thresh": 51,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": false,
+ "performance": true,
+ "prefailure": true,
+ "string": "POS--K ",
+ "updated_online": true,
+ "value": 39
+ },
+ "id": 3,
+ "name": "Spin_Up_Time",
+ "raw": {
+ "string": "4250",
+ "value": 4250
+ },
+ "thresh": 21,
+ "value": 175,
+ "when_failed": "",
+ "worst": 172
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 4,
+ "name": "Start_Stop_Count",
+ "raw": {
+ "string": "1657",
+ "value": 1657
+ },
+ "thresh": 0,
+ "value": 99,
+ "when_failed": "",
+ "worst": 99
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": true,
+ "string": "PO--CK ",
+ "updated_online": true,
+ "value": 51
+ },
+ "id": 5,
+ "name": "Reallocated_Sector_Ct",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 140,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": true,
+ "event_count": false,
+ "performance": true,
+ "prefailure": false,
+ "string": "-OSR-K ",
+ "updated_online": true,
+ "value": 46
+ },
+ "id": 7,
+ "name": "Seek_Error_Rate",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 9,
+ "name": "Power_On_Hours",
+ "raw": {
+ "string": "15807",
+ "value": 15807
+ },
+ "thresh": 0,
+ "value": 79,
+ "when_failed": "",
+ "worst": 79
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 10,
+ "name": "Spin_Retry_Count",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 100,
+ "when_failed": "",
+ "worst": 100
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 11,
+ "name": "Calibration_Retry_Count",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 100,
+ "when_failed": "",
+ "worst": 100
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 12,
+ "name": "Power_Cycle_Count",
+ "raw": {
+ "string": "1370",
+ "value": 1370
+ },
+ "thresh": 0,
+ "value": 99,
+ "when_failed": "",
+ "worst": 99
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 192,
+ "name": "Power-Off_Retract_Count",
+ "raw": {
+ "string": "111",
+ "value": 111
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 193,
+ "name": "Load_Cycle_Count",
+ "raw": {
+ "string": "1545",
+ "value": 1545
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": false,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O---K ",
+ "updated_online": true,
+ "value": 34
+ },
+ "id": 194,
+ "name": "Temperature_Celsius",
+ "raw": {
+ "string": "47",
+ "value": 47
+ },
+ "thresh": 0,
+ "value": 100,
+ "when_failed": "",
+ "worst": 89
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 196,
+ "name": "Reallocated_Event_Count",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 197,
+ "name": "Current_Pending_Sector",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "----CK ",
+ "updated_online": false,
+ "value": 48
+ },
+ "id": 198,
+ "name": "Offline_Uncorrectable",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": true,
+ "error_rate": false,
+ "event_count": true,
+ "performance": false,
+ "prefailure": false,
+ "string": "-O--CK ",
+ "updated_online": true,
+ "value": 50
+ },
+ "id": 199,
+ "name": "UDMA_CRC_Error_Count",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ },
+ {
+ "flags": {
+ "auto_keep": false,
+ "error_rate": true,
+ "event_count": false,
+ "performance": false,
+ "prefailure": false,
+ "string": "---R-- ",
+ "updated_online": false,
+ "value": 8
+ },
+ "id": 200,
+ "name": "Multi_Zone_Error_Rate",
+ "raw": {
+ "string": "0",
+ "value": 0
+ },
+ "thresh": 0,
+ "value": 200,
+ "when_failed": "",
+ "worst": 200
+ }
+ ]
+ },
+ "ata_smart_data": {
+ "capabilities": {
+ "attribute_autosave_enabled": true,
+ "conveyance_self_test_supported": true,
+ "error_logging_supported": true,
+ "exec_offline_immediate_supported": true,
+ "gp_logging_supported": true,
+ "offline_is_aborted_upon_new_cmd": false,
+ "offline_surface_scan_supported": true,
+ "selective_self_test_supported": true,
+ "self_tests_supported": true,
+ "values": [
+ 123,
+ 3
+ ]
+ },
+ "offline_data_collection": {
+ "completion_seconds": 16500,
+ "status": {
+ "string": "was suspended by an interrupting command from host",
+ "value": 132
+ }
+ },
+ "self_test": {
+ "polling_minutes": {
+ "conveyance": 5,
+ "extended": 162,
+ "short": 2
+ },
+ "status": {
+ "passed": true,
+ "string": "completed without error",
+ "value": 0
+ }
+ }
+ },
+ "ata_smart_error_log": {
+ "summary": {
+ "count": 0,
+ "revision": 1
+ }
+ },
+ "ata_smart_selective_self_test_log": {
+ "flags": {
+ "remainder_scan_enabled": false,
+ "value": 0
+ },
+ "power_up_scan_resume_minutes": 0,
+ "revision": 1,
+ "table": [
+ {
+ "lba_max": 0,
+ "lba_min": 0,
+ "status": {
+ "string": "Not_testing",
+ "value": 0
+ }
+ },
+ {
+ "lba_max": 0,
+ "lba_min": 0,
+ "status": {
+ "string": "Not_testing",
+ "value": 0
+ }
+ },
+ {
+ "lba_max": 0,
+ "lba_min": 0,
+ "status": {
+ "string": "Not_testing",
+ "value": 0
+ }
+ },
+ {
+ "lba_max": 0,
+ "lba_min": 0,
+ "status": {
+ "string": "Not_testing",
+ "value": 0
+ }
+ },
+ {
+ "lba_max": 0,
+ "lba_min": 0,
+ "status": {
+ "string": "Not_testing",
+ "value": 0
+ }
+ }
+ ]
+ },
+ "ata_smart_self_test_log": {
+ "standard": {
+ "count": 0,
+ "revision": 1
+ }
+ },
+ "ata_version": {
+ "major_value": 510,
+ "minor_value": 0,
+ "string": "ATA8-ACS (minor revision not indicated)"
+ },
+ "device": {
+ "info_name": "/dev/sde [SAT]",
+ "name": "/dev/sde",
+ "protocol": "ATA",
+ "type": "sat"
+ },
+ "firmware_version": "01.01V02",
+ "in_smartctl_database": true,
+ "interface_speed": {
+ "current": {
+ "bits_per_unit": 100000000,
+ "sata_value": 2,
+ "string": "3.0 Gb/s",
+ "units_per_second": 30
+ },
+ "max": {
+ "bits_per_unit": 100000000,
+ "sata_value": 6,
+ "string": "3.0 Gb/s",
+ "units_per_second": 30
+ }
+ },
+ "json_format_version": [
+ 1,
+ 0
+ ],
+ "local_time": {
+ "asctime": "Mon Sep 2 12:39:01 2019 UTC",
+ "time_t": 1567427941
+ },
+ "logical_block_size": 512,
+ "model_family": "Western Digital RE4",
+ "model_name": "WDC WD1003FBYX-01Y7B1",
+ "nvme_smart_health_information_add_log_error": "nvme returned an error: sudo: exit status: 1",
+ "nvme_smart_health_information_add_log_error_code": -22,
+ "nvme_vendor": "wdc_wd1003fbyx-01y7b1",
+ "physical_block_size": 512,
+ "power_cycle_count": 1370,
+ "power_on_time": {
+ "hours": 15807
+ },
+ "rotation_rate": 7200,
+ "sata_version": {
+ "string": "SATA 3.0",
+ "value": 63
+ },
+ "serial_number": "WD-WCAW11111111",
+ "smart_status": {
+ "passed": true
+ },
+ "smartctl": {
+ "argv": [
+ "smartctl",
+ "-a",
+ "/dev/sde",
+ "--json"
+ ],
+ "build_info": "(SUSE RPM)",
+ "exit_status": 0,
+ "platform_info": "x86_64-linux-5.0.0-25-generic",
+ "svn_revision": "4917",
+ "version": [
+ 7,
+ 0
+ ]
+ },
+ "temperature": {
+ "current": 47
+ },
+ "user_capacity": {
+ "blocks": 1953525168,
+ "bytes": 1000204886016
+ },
+ "wwn": {
+ "id": 11601695629,
+ "naa": 5,
+ "oui": 5358
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_nvme_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_nvme_response.json
new file mode 100644
index 000000000..fce50658a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_nvme_response.json
@@ -0,0 +1,134 @@
+{
+ "Samsung_SSD_970_EVO_Plus_1TB_S4EXXXXXXXXXXXX": {
+ "device": {
+ "info_name": "/dev/nvme0n1",
+ "name": "/dev/nvme0n1",
+ "protocol": "NVMe",
+ "type": "nvme"
+ },
+ "firmware_version": "1B2QEXM7",
+ "json_format_version": [1, 0],
+ "local_time": { "asctime": "Thu Oct 24 10:17:06 2019 CEST", "time_t": 1571905026 },
+ "logical_block_size": 512,
+ "model_name": "Samsung SSD 970 EVO Plus 1TB",
+ "nvme_controller_id": 4,
+ "nvme_ieee_oui_identifier": 9528,
+ "nvme_namespaces": [
+ {
+ "capacity": { "blocks": 1953525168, "bytes": 1000204886016 },
+ "eui64": { "ext_id": 367510189547, "oui": 9528 },
+ "formatted_lba_size": 512,
+ "id": 1,
+ "size": { "blocks": 1953525168, "bytes": 1000204886016 },
+ "utilization": { "blocks": 102347056, "bytes": 52401692672 }
+ }
+ ],
+ "nvme_number_of_namespaces": 1,
+ "nvme_pci_vendor": { "id": 5197, "subsystem_id": 5197 },
+ "nvme_smart_health_information_add_log_error": "nvme returned an error: sudo: exit status: 231",
+ "nvme_smart_health_information_add_log_error_code": -22,
+ "nvme_smart_health_information_log": {
+ "available_spare": 100,
+ "available_spare_threshold": 10,
+ "controller_busy_time": 29,
+ "critical_comp_time": 0,
+ "critical_warning": 0,
+ "data_units_read": 28800,
+ "data_units_written": 558814,
+ "host_reads": 480163,
+ "host_writes": 2340561,
+ "media_errors": 0,
+ "num_err_log_entries": 2,
+ "percentage_used": 0,
+ "power_cycles": 4,
+ "power_on_hours": 13,
+ "temperature": 42,
+ "temperature_sensors": [42, 46],
+ "unsafe_shutdowns": 2,
+ "warning_temp_time": 0
+ },
+ "nvme_total_capacity": 1000204886016,
+ "nvme_unallocated_capacity": 0,
+ "nvme_vendor": "samsung",
+ "power_cycle_count": 4,
+ "power_on_time": { "hours": 13 },
+ "serial_number": "S4EXXXXXXXXXXXX",
+ "smart_status": { "nvme": { "value": 0 }, "passed": true },
+ "smartctl": {
+ "argv": ["smartctl", "-a", "--json=o", "/dev/nvme0n1"],
+ "build_info": "(local build)",
+ "exit_status": 0,
+ "output": [
+ "smartctl 7.0 2018-12-30 r4883 [x86_64-linux-5.0.0-32-generic] (local build)",
+ "Copyright (C) 2002-18, Bruce Allen, Christian Franke, www.smartmontools.org",
+ "",
+ "=== START OF INFORMATION SECTION ===",
+ "Model Number: Samsung SSD 970 EVO Plus 1TB",
+ "Serial Number: S4EXXXXXXXXXXXX",
+ "Firmware Version: 1B2QEXM7",
+ "PCI Vendor/Subsystem ID: 0x144d",
+ "IEEE OUI Identifier: 0x002538",
+ "Total NVM Capacity: 1.000.204.886.016 [1,00 TB]",
+ "Unallocated NVM Capacity: 0",
+ "Controller ID: 4",
+ "Number of Namespaces: 1",
+ "Namespace 1 Size/Capacity: 1.000.204.886.016 [1,00 TB]",
+ "Namespace 1 Utilization: 52.401.692.672 [52,4 GB]",
+ "Namespace 1 Formatted LBA Size: 512",
+ "Namespace 1 IEEE EUI-64: 002538 55915075eb",
+ "Local Time is: Thu Oct 24 10:17:06 2019 CEST",
+ "Firmware Updates (0x16): 3 Slots, no Reset required",
+ "Optional Admin Commands (0x0017): Security Format Frmw_DL Self_Test",
+ "Optional NVM Commands (0x005f): Comp Wr_Unc DS_Mngmt Wr_Zero Sav/Sel_Feat Timestmp",
+ "Maximum Data Transfer Size: 512 Pages",
+ "Warning Comp. Temp. Threshold: 85 Celsius",
+ "Critical Comp. Temp. Threshold: 85 Celsius",
+ "",
+ "Supported Power States",
+ "St Op Max Active Idle RL RT WL WT Ent_Lat Ex_Lat",
+ " 0 + 7.80W - - 0 0 0 0 0 0",
+ " 1 + 6.00W - - 1 1 1 1 0 0",
+ " 2 + 3.40W - - 2 2 2 2 0 0",
+ " 3 - 0.0700W - - 3 3 3 3 210 1200",
+ " 4 - 0.0100W - - 4 4 4 4 2000 8000",
+ "",
+ "Supported LBA Sizes (NSID 0x1)",
+ "Id Fmt Data Metadt Rel_Perf",
+ " 0 + 512 0 0",
+ "",
+ "=== START OF SMART DATA SECTION ===",
+ "SMART overall-health self-assessment test result: PASSED",
+ "",
+ "SMART/Health Information (NVMe Log 0x02)",
+ "Critical Warning: 0x00",
+ "Temperature: 42 Celsius",
+ "Available Spare: 100%",
+ "Available Spare Threshold: 10%",
+ "Percentage Used: 0%",
+ "Data Units Read: 28.800 [14,7 GB]",
+ "Data Units Written: 558.814 [286 GB]",
+ "Host Read Commands: 480.163",
+ "Host Write Commands: 2.340.561",
+ "Controller Busy Time: 29",
+ "Power Cycles: 4",
+ "Power On Hours: 13",
+ "Unsafe Shutdowns: 2",
+ "Media and Data Integrity Errors: 0",
+ "Error Information Log Entries: 2",
+ "Warning Comp. Temperature Time: 0",
+ "Critical Comp. Temperature Time: 0",
+ "Temperature Sensor 1: 42 Celsius",
+ "Temperature Sensor 2: 46 Celsius",
+ "",
+ "Error Information (NVMe Log 0x01, max 64 entries)",
+ "No Errors Logged",
+ ""
+ ],
+ "platform_info": "x86_64-linux-5.0.0-32-generic",
+ "svn_revision": "4883",
+ "version": [7, 0]
+ },
+ "temperature": { "current": 42 },
+ "user_capacity": { "blocks": 1953525168, "bytes": 1000204886016 }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_scsi_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_scsi_response.json
new file mode 100644
index 000000000..dfbe580c7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/fixtures/smart_data_version_1_0_scsi_response.json
@@ -0,0 +1,208 @@
+{
+ "WDC_WUH721818AL5204_012345689": {
+ "device": {
+ "info_name": "/dev/sdf",
+ "name": "/dev/sdf",
+ "protocol": "SCSI",
+ "type": "scsi"
+ },
+ "device_type": {
+ "name": "disk",
+ "scsi_value": 0
+ },
+ "form_factor": {
+ "name": "3.5 inches",
+ "scsi_value": 2
+ },
+ "json_format_version": [
+ 1,
+ 0
+ ],
+ "local_time": {
+ "asctime": "Sun May 8 14:21:11 2022 UTC",
+ "time_t": 1652019671
+ },
+ "logical_block_size": 512,
+ "model_name": "WDC WUH721818AL5204",
+ "nvme_smart_health_information_add_log_error": "nvme returned an error: sudo: exit status: 231",
+ "nvme_smart_health_information_add_log_error_code": -22,
+ "nvme_vendor": "wdc",
+ "physical_block_size": 4096,
+ "power_on_time": {
+ "hours": 1719,
+ "minutes": 55
+ },
+ "product": "WUH721818AL5204",
+ "revision": "C232",
+ "rotation_rate": 7200,
+ "scsi_error_counter_log": {
+ "read": {
+ "correction_algorithm_invocations": 1001,
+ "errors_corrected_by_eccdelayed": 0,
+ "errors_corrected_by_eccfast": 0,
+ "errors_corrected_by_rereads_rewrites": 0,
+ "gigabytes_processed": "8519.006",
+ "total_errors_corrected": 0,
+ "total_uncorrected_errors": 0
+ },
+ "verify": {
+ "correction_algorithm_invocations": 261,
+ "errors_corrected_by_eccdelayed": 0,
+ "errors_corrected_by_eccfast": 0,
+ "errors_corrected_by_rereads_rewrites": 0,
+ "gigabytes_processed": "0.000",
+ "total_errors_corrected": 0,
+ "total_uncorrected_errors": 0
+ },
+ "write": {
+ "correction_algorithm_invocations": 25720,
+ "errors_corrected_by_eccdelayed": 0,
+ "errors_corrected_by_eccfast": 0,
+ "errors_corrected_by_rereads_rewrites": 0,
+ "gigabytes_processed": "146241.629",
+ "total_errors_corrected": 0,
+ "total_uncorrected_errors": 0
+ }
+ },
+ "scsi_grown_defect_list": 0,
+ "scsi_version": "SPC-5",
+ "serial_number": "0123456789",
+ "smart_status": {
+ "passed": true
+ },
+ "smartctl": {
+ "argv": [
+ "smartctl",
+ "-x",
+ "--json=o",
+ "/dev/sdf"
+ ],
+ "build_info": "(local build)",
+ "exit_status": 0,
+ "output": [
+ "smartctl 7.1 2020-04-05 r5049 [x86_64-linux-4.18.0-348.2.1.el8_5.x86_64] (local build)",
+ "Copyright (C) 2002-19, Bruce Allen, Christian Franke, www.smartmontools.org",
+ "",
+ "=== START OF INFORMATION SECTION ===",
+ "Vendor: WDC",
+ "Product: WUH721818AL5204",
+ "Revision: C232",
+ "Compliance: SPC-5",
+ "User Capacity: 18,000,207,937,536 bytes [18.0 TB]",
+ "Logical block size: 512 bytes",
+ "Physical block size: 4096 bytes",
+ "LU is fully provisioned",
+ "Rotation Rate: 7200 rpm",
+ "Form Factor: 3.5 inches",
+ "Logical Unit id: 0xffffffffffffffffffffffff",
+ "Serial number: 0123456789",
+ "Device type: disk",
+ "Transport protocol: SAS (SPL-3)",
+ "Local Time is: Sun May 8 14:21:11 2022 UTC",
+ "SMART support is: Available - device has SMART capability.",
+ "SMART support is: Enabled",
+ "Temperature Warning: Enabled",
+ "Read Cache is: Enabled",
+ "Writeback Cache is: Enabled",
+ "",
+ "=== START OF READ SMART DATA SECTION ===",
+ "SMART Health Status: OK",
+ "",
+ "Grown defects during certification <not available>",
+ "Total blocks reassigned during format <not available>",
+ "Total new blocks reassigned <not available>",
+ "Power on minutes since format <not available>",
+ "Current Drive Temperature: 38 C",
+ "Drive Trip Temperature: 85 C",
+ "",
+ "Manufactured in week 43 of year 2021",
+ "Specified cycle count over device lifetime: 50000",
+ "Accumulated start-stop cycles: 9",
+ "Specified load-unload count over device lifetime: 600000",
+ "Accumulated load-unload cycles: 74",
+ "Elements in grown defect list: 0",
+ "",
+ "Error counter log:",
+ " Errors Corrected by Total Correction Gigabytes Total",
+ " ECC rereads/ errors algorithm processed uncorrected",
+ " fast | delayed rewrites corrected invocations [10^9 bytes] errors",
+ "read: 0 0 0 0 1001 8519.006 0",
+ "write: 0 0 0 0 25720 146241.629 0",
+ "verify: 0 0 0 0 261 0.000 0",
+ "",
+ "Non-medium error count: 0",
+ "",
+ "No Self-tests have been logged",
+ "",
+ "Background scan results log",
+ " Status: waiting until BMS interval timer expires",
+ " Accumulated power on time, hours:minutes 1719:55 [103195 minutes]",
+ " Number of background scans performed: 5, scan progress: 0.00%",
+ " Number of background medium scans performed: 5",
+ "",
+ "Protocol Specific port log page for SAS SSP",
+ "relative target port id = 1",
+ " generation code = 3",
+ " number of phys = 1",
+ " phy identifier = 0",
+ " attached device type: expander device",
+ " attached reason: loss of dword synchronization",
+ " reason: unknown",
+ " negotiated logical link rate: phy enabled; 12 Gbps",
+ " attached initiator port: ssp=0 stp=0 smp=1",
+ " attached target port: ssp=0 stp=0 smp=1",
+ " SAS address = 0xffffffffffffffffffffffff",
+ " attached SAS address = 0xffffffffffffffffffffffff",
+ " attached phy identifier = 0",
+ " Invalid DWORD count = 0",
+ " Running disparity error count = 0",
+ " Loss of DWORD synchronization = 0",
+ " Phy reset problem = 0",
+ " Phy event descriptors:",
+ " Invalid word count: 0",
+ " Running disparity error count: 0",
+ " Loss of dword synchronization count: 0",
+ " Phy reset problem count: 0",
+ "relative target port id = 2",
+ " generation code = 3",
+ " number of phys = 1",
+ " phy identifier = 1",
+ " attached device type: expander device",
+ " attached reason: power on",
+ " reason: unknown",
+ " negotiated logical link rate: phy enabled; 12 Gbps",
+ " attached initiator port: ssp=0 stp=0 smp=1",
+ " attached target port: ssp=0 stp=0 smp=1",
+ " SAS address = 0xffffffffffffffffffffffff",
+ " attached SAS address = 0xffffffffffffffffffffffff",
+ " attached phy identifier = 0",
+ " Invalid DWORD count = 0",
+ " Running disparity error count = 0",
+ " Loss of DWORD synchronization = 0",
+ " Phy reset problem = 0",
+ " Phy event descriptors:",
+ " Invalid word count: 0",
+ " Running disparity error count: 0",
+ " Loss of dword synchronization count: 0",
+ " Phy reset problem count: 0",
+ ""
+ ],
+ "platform_info": "x86_64-linux-4.18.0-348.2.1.el8_5.x86_64",
+ "svn_revision": "5049",
+ "version": [
+ 7,
+ 1
+ ]
+ },
+ "temperature": {
+ "current": 38,
+ "drive_trip": 85
+ },
+ "user_capacity": {
+ "blocks": 35156656128,
+ "bytes": 18000207937536
+ },
+ "vendor": "WDC"
+ }
+ }
+ \ No newline at end of file
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.html
new file mode 100644
index 000000000..805d7558e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.html
@@ -0,0 +1,110 @@
+<ng-container *ngIf="!loading; else isLoading">
+ <cd-alert-panel *ngIf="error"
+ type="error"
+ i18n>Failed to retrieve SMART data.</cd-alert-panel>
+ <cd-alert-panel *ngIf="incompatible"
+ type="warning"
+ i18n>The data received has the JSON format version 2.x and is currently incompatible with the
+ dashboard.</cd-alert-panel>
+
+ <ng-container *ngIf="!error && !incompatible">
+ <cd-alert-panel *ngIf="data | pipeFunction:isEmpty"
+ type="info"
+ i18n>No SMART data available.</cd-alert-panel>
+
+ <ng-container *ngIf="!(data | pipeFunction:isEmpty)">
+ <ul ngbNav
+ #nav="ngbNav"
+ class="nav-tabs">
+ <li ngbNavItem
+ *ngFor="let device of data | keyvalue">
+ <a ngbNavLink>{{ device.value.device }} ({{ device.value.identifier }})</a>
+ <ng-template ngbNavContent>
+ <ng-container *ngIf="device.value.error; else noError">
+ <cd-alert-panel id="alert-error"
+ type="warning">{{ device.value.userMessage }}</cd-alert-panel>
+ </ng-container>
+
+ <ng-template #noError>
+ <cd-alert-panel *ngIf="device.value.info?.smart_status | pipeFunction:isEmpty; else hasSmartStatus"
+ id="alert-self-test-unknown"
+ size="slim"
+ type="warning"
+ i18n-title
+ title="SMART overall-health self-assessment test result"
+ i18n>unknown</cd-alert-panel>
+ <ng-template #hasSmartStatus>
+ <!-- HDD/NVMe self test -->
+ <ng-container *ngIf="device.value.info.smart_status.passed; else selfTestFailed">
+ <cd-alert-panel id="alert-self-test-passed"
+ size="slim"
+ type="info"
+ i18n-title
+ title="SMART overall-health self-assessment test result"
+ i18n>passed</cd-alert-panel>
+ </ng-container>
+ <ng-template #selfTestFailed>
+ <cd-alert-panel id="alert-self-test-failed"
+ size="slim"
+ type="warning"
+ i18n-title
+ title="SMART overall-health self-assessment test result"
+ i18n>failed</cd-alert-panel>
+ </ng-template>
+ </ng-template>
+ </ng-template>
+
+ <ng-container *ngIf="!(device.value.info | pipeFunction:isEmpty) || !(device.value.smart | pipeFunction:isEmpty)">
+ <ul ngbNav
+ #innerNav="ngbNav"
+ class="nav-tabs">
+ <li [ngbNavItem]="1">
+ <a ngbNavLink
+ i18n>Device Information</a>
+ <ng-template ngbNavContent>
+ <cd-table-key-value *ngIf="!(device.value.info | pipeFunction:isEmpty)"
+ [renderObjects]="true"
+ [data]="device.value.info"></cd-table-key-value>
+ <cd-alert-panel *ngIf="device.value.info | pipeFunction:isEmpty"
+ id="alert-device-info-unavailable"
+ type="info"
+ i18n>No device information available for this device.</cd-alert-panel>
+ </ng-template>
+ </li>
+ <li [ngbNavItem]="2">
+ <a ngbNavLink
+ i18n>SMART</a>
+ <ng-template ngbNavContent>
+ <cd-table *ngIf="device.value.smart?.attributes"
+ [data]="device.value.smart.attributes.table"
+ updateSelectionOnRefresh="never"
+ [columns]="smartDataColumns"></cd-table>
+ <cd-table-key-value *ngIf="device.value.smart?.scsi_error_counter_log"
+ [renderObjects]="true"
+ [data]="device.value.smart"
+ updateSelectionOnRefresh="never"></cd-table-key-value>
+ <cd-table-key-value *ngIf="device.value.smart?.nvmeData"
+ [renderObjects]="true"
+ [data]="device.value.smart.nvmeData"
+ updateSelectionOnRefresh="never"></cd-table-key-value>
+ <cd-alert-panel *ngIf="!device.value.smart?.attributes && !device.value.smart?.nvmeData && !device.value.smart?.scsi_error_counter_log"
+ id="alert-device-smart-data-unavailable"
+ type="info"
+ i18n>No SMART data available for this device.</cd-alert-panel>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="innerNav"></div>
+ </ng-container>
+ </ng-template>
+ </li>
+ </ul>
+
+ <div [ngbNavOutlet]="nav"></div>
+ </ng-container>
+ </ng-container>
+</ng-container>
+<ng-template #isLoading>
+ <cd-loading-panel i18n>SMART data is loading.</cd-loading-panel>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.spec.ts
new file mode 100644
index 000000000..54c436ca6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.spec.ts
@@ -0,0 +1,264 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { SimpleChange, SimpleChanges } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+import { of } from 'rxjs';
+
+import { OsdService } from '~/app/shared/api/osd.service';
+import {
+ AtaSmartDataV1,
+ IscsiSmartDataV1,
+ NvmeSmartDataV1,
+ SmartDataResult
+} from '~/app/shared/models/smart';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SmartListComponent } from './smart-list.component';
+
+describe('OsdSmartListComponent', () => {
+ let component: SmartListComponent;
+ let fixture: ComponentFixture<SmartListComponent>;
+ let osdService: OsdService;
+
+ const SMART_DATA_ATA_VERSION_1_0: AtaSmartDataV1 = require('./fixtures/smart_data_version_1_0_ata_response.json');
+ const SMART_DATA_NVME_VERSION_1_0: NvmeSmartDataV1 = require('./fixtures/smart_data_version_1_0_nvme_response.json');
+ const SMART_DATA_SCSI_VERSION_1_0: IscsiSmartDataV1 = require('./fixtures/smart_data_version_1_0_scsi_response.json');
+
+ /**
+ * Sets attributes for _all_ returned devices according to the given path. The syntax is the same
+ * as used in lodash.set().
+ *
+ * @example
+ * patchData('json_format_version', [2, 0]) // sets the value of `json_format_version` to [2, 0]
+ * // for all devices
+ *
+ * patchData('json_format_version[0]', 2) // same result
+ *
+ * @param path The path to the attribute
+ * @param newValue The new value
+ */
+ const patchData = (path: string, newValue: any): any => {
+ return _.reduce(
+ _.cloneDeep(SMART_DATA_ATA_VERSION_1_0),
+ (result: object, dataObj, deviceId) => {
+ result[deviceId] = _.set<any>(dataObj, path, newValue);
+ return result;
+ },
+ {}
+ );
+ };
+
+ /**
+ * Initializes the component after it spied upon the `getSmartData()` method
+ * of `OsdService`. Determines which data is returned.
+ */
+ const initializeComponentWithData = (
+ dataType: 'hdd_v1' | 'nvme_v1' | 'hdd_v1_scsi',
+ patch: { [path: string]: any } = null,
+ simpleChanges?: SimpleChanges
+ ) => {
+ let data: AtaSmartDataV1 | NvmeSmartDataV1 | IscsiSmartDataV1;
+ switch (dataType) {
+ case 'hdd_v1':
+ data = SMART_DATA_ATA_VERSION_1_0;
+ break;
+ case 'nvme_v1':
+ data = SMART_DATA_NVME_VERSION_1_0;
+ break;
+ case 'hdd_v1_scsi':
+ data = SMART_DATA_SCSI_VERSION_1_0;
+ break;
+ }
+
+ if (_.isObject(patch)) {
+ _.each(patch, (replacement, path) => {
+ data = patchData(path, replacement);
+ });
+ }
+
+ spyOn(osdService, 'getSmartData').and.callFake(() => of(data));
+ component.ngOnInit();
+ const changes: SimpleChanges = simpleChanges || {
+ osdId: new SimpleChange(null, 0, true)
+ };
+ component.ngOnChanges(changes);
+ };
+
+ /**
+ * Verify an alert panel and its attributes.
+ *
+ * @param selector The CSS selector for the alert panel.
+ * @param panelTitle The title should be displayed.
+ * @param panelType Alert level of panel. Can be in `warning` or `info`.
+ * @param panelSize Pass `slim` for slim alert panel.
+ */
+ const verifyAlertPanel = (
+ selector: string,
+ panelTitle: string,
+ panelType: 'warning' | 'info',
+ panelSize?: 'slim'
+ ) => {
+ const alertPanel = fixture.debugElement.query(By.css(selector));
+ expect(component.incompatible).toBe(false);
+ expect(component.loading).toBe(false);
+
+ expect(alertPanel.attributes.type).toBe(panelType);
+ if (panelSize === 'slim') {
+ expect(alertPanel.attributes.title).toBe(panelTitle);
+ expect(alertPanel.attributes.size).toBe(panelSize);
+ } else {
+ const panelText = alertPanel.query(By.css('.alert-panel-text'));
+ expect(panelText.nativeElement.textContent).toBe(panelTitle);
+ }
+ };
+
+ configureTestBed({
+ declarations: [SmartListComponent],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ HttpClientTestingModule,
+ NgbNavModule,
+ NgxPipeFunctionModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SmartListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ osdService = TestBed.inject(OsdService);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('tests ATA version 1.x', () => {
+ beforeEach(() => initializeComponentWithData('hdd_v1'));
+
+ it('should return with proper keys', () => {
+ _.each(component.data, (smartData, _deviceId) => {
+ expect(_.keys(smartData)).toEqual(['info', 'smart', 'device', 'identifier']);
+ });
+ });
+
+ it('should not contain excluded keys in `info`', () => {
+ const excludes = [
+ 'ata_smart_attributes',
+ 'ata_smart_selective_self_test_log',
+ 'ata_smart_data'
+ ];
+ _.each(component.data, (smartData: SmartDataResult, _deviceId) => {
+ _.each(excludes, (exclude) => expect(smartData.info[exclude]).toBeUndefined());
+ });
+ });
+ });
+
+ describe('tests NVMe version 1.x', () => {
+ beforeEach(() => initializeComponentWithData('nvme_v1'));
+
+ it('should return with proper keys', () => {
+ _.each(component.data, (smartData, _deviceId) => {
+ expect(_.keys(smartData)).toEqual(['info', 'smart', 'device', 'identifier']);
+ });
+ });
+
+ it('should not contain excluded keys in `info`', () => {
+ const excludes = ['nvme_smart_health_information_log'];
+ _.each(component.data, (smartData: SmartDataResult, _deviceId) => {
+ _.each(excludes, (exclude) => expect(smartData.info[exclude]).toBeUndefined());
+ });
+ });
+ });
+
+ describe('tests SCSI version 1.x', () => {
+ beforeEach(() => initializeComponentWithData('hdd_v1_scsi'));
+
+ it('should return with proper keys', () => {
+ _.each(component.data, (smartData, _deviceId) => {
+ expect(_.keys(smartData)).toEqual(['info', 'smart', 'device', 'identifier']);
+ });
+ });
+
+ it('should not contain excluded keys in `info`', () => {
+ const excludes = ['scsi_error_counter_log', 'scsi_grown_defect_list'];
+ _.each(component.data, (smartData: SmartDataResult, _deviceId) => {
+ _.each(excludes, (exclude) => expect(smartData.info[exclude]).toBeUndefined());
+ });
+ });
+ });
+
+ it('should not work for version 2.x', () => {
+ initializeComponentWithData('nvme_v1', { json_format_version: [2, 0] });
+ expect(component.data).toEqual({});
+ expect(component.incompatible).toBeTruthy();
+ });
+
+ it('should display info panel for passed self test', () => {
+ initializeComponentWithData('hdd_v1');
+ fixture.detectChanges();
+ verifyAlertPanel(
+ 'cd-alert-panel#alert-self-test-passed',
+ 'SMART overall-health self-assessment test result',
+ 'info',
+ 'slim'
+ );
+ });
+
+ it('should display warning panel for failed self test', () => {
+ initializeComponentWithData('hdd_v1', { 'smart_status.passed': false });
+ fixture.detectChanges();
+ verifyAlertPanel(
+ 'cd-alert-panel#alert-self-test-failed',
+ 'SMART overall-health self-assessment test result',
+ 'warning',
+ 'slim'
+ );
+ });
+
+ it('should display warning panel for unknown self test', () => {
+ initializeComponentWithData('hdd_v1', { smart_status: undefined });
+ fixture.detectChanges();
+ verifyAlertPanel(
+ 'cd-alert-panel#alert-self-test-unknown',
+ 'SMART overall-health self-assessment test result',
+ 'warning',
+ 'slim'
+ );
+ });
+
+ it('should display info panel for empty device info', () => {
+ initializeComponentWithData('hdd_v1');
+ const deviceId: string = _.keys(component.data)[0];
+ component.data[deviceId]['info'] = {};
+ fixture.detectChanges();
+ component.nav.select(1);
+ fixture.detectChanges();
+ verifyAlertPanel(
+ 'cd-alert-panel#alert-device-info-unavailable',
+ 'No device information available for this device.',
+ 'info'
+ );
+ });
+
+ it('should display info panel for empty SMART data', () => {
+ initializeComponentWithData('hdd_v1');
+ const deviceId: string = _.keys(component.data)[0];
+ component.data[deviceId]['smart'] = {};
+ fixture.detectChanges();
+ component.nav.select(2);
+ fixture.detectChanges();
+ verifyAlertPanel(
+ 'cd-alert-panel#alert-device-smart-data-unavailable',
+ 'No SMART data available for this device.',
+ 'info'
+ );
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.ts
new file mode 100644
index 000000000..abfdcfe5b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.ts
@@ -0,0 +1,212 @@
+import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
+
+import { NgbNav } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OsdService } from '~/app/shared/api/osd.service';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import {
+ AtaSmartDataV1,
+ IscsiSmartDataV1,
+ NvmeSmartDataV1,
+ SmartDataResult,
+ SmartError,
+ SmartErrorResult
+} from '~/app/shared/models/smart';
+
+@Component({
+ selector: 'cd-smart-list',
+ templateUrl: './smart-list.component.html',
+ styleUrls: ['./smart-list.component.scss']
+})
+export class SmartListComponent implements OnInit, OnChanges {
+ @ViewChild('innerNav')
+ nav: NgbNav;
+
+ @Input()
+ osdId: number = null;
+ @Input()
+ hostname: string = null;
+
+ loading = false;
+ incompatible = false;
+ error = false;
+
+ data: { [deviceId: string]: SmartDataResult | SmartErrorResult } = {};
+
+ smartDataColumns: CdTableColumn[];
+ scsiSmartDataColumns: CdTableColumn[];
+
+ isEmpty = _.isEmpty;
+
+ constructor(private osdService: OsdService, private hostService: HostService) {}
+
+ isSmartError(data: any): data is SmartError {
+ return _.get(data, 'error') !== undefined;
+ }
+
+ isNvmeSmartData(data: any): data is NvmeSmartDataV1 {
+ return _.get(data, 'device.protocol', '').toLowerCase() === 'nvme';
+ }
+
+ isAtaSmartData(data: any): data is AtaSmartDataV1 {
+ return _.get(data, 'device.protocol', '').toLowerCase() === 'ata';
+ }
+
+ isIscsiSmartData(data: any): data is IscsiSmartDataV1 {
+ return _.get(data, 'device.protocol', '').toLowerCase() === 'scsi';
+ }
+
+ private fetchData(data: any) {
+ const result: { [deviceId: string]: SmartDataResult | SmartErrorResult } = {};
+ _.each(data, (smartData, deviceId) => {
+ if (this.isSmartError(smartData)) {
+ let userMessage = '';
+ if (smartData.smartctl_error_code === -22) {
+ userMessage = $localize`Smartctl has received an unknown argument \
+(error code ${smartData.smartctl_error_code}). \
+You may be using an incompatible version of smartmontools. Version >= 7.0 of \
+smartmontools is required to successfully retrieve data.`;
+ } else {
+ userMessage = $localize`An error with error code ${smartData.smartctl_error_code} occurred.`;
+ }
+ const _result: SmartErrorResult = {
+ error: smartData.error,
+ smartctl_error_code: smartData.smartctl_error_code,
+ smartctl_output: smartData.smartctl_output,
+ userMessage: userMessage,
+ device: smartData.dev,
+ identifier: smartData.nvme_vendor
+ };
+ result[deviceId] = _result;
+ return;
+ }
+ // Prepare S.M.A.R.T data
+ if (smartData.json_format_version[0] === 1) {
+ // Version 1.x
+ if (this.isAtaSmartData(smartData)) {
+ result[deviceId] = this.extractAtaData(smartData);
+ } else if (this.isIscsiSmartData(smartData)) {
+ result[deviceId] = this.extractIscsiData(smartData);
+ } else if (this.isNvmeSmartData(smartData)) {
+ result[deviceId] = this.extractNvmeData(smartData);
+ }
+ return;
+ } else {
+ this.incompatible = true;
+ }
+ });
+ this.data = result;
+ this.loading = false;
+ }
+
+ private extractNvmeData(smartData: NvmeSmartDataV1): SmartDataResult {
+ const info = _.omitBy(smartData, (_value: string, key: string) =>
+ ['nvme_smart_health_information_log'].includes(key)
+ );
+ return {
+ info: info,
+ smart: {
+ nvmeData: smartData.nvme_smart_health_information_log
+ },
+ device: smartData.device.name,
+ identifier: smartData.serial_number
+ };
+ }
+
+ private extractIscsiData(smartData: IscsiSmartDataV1): SmartDataResult {
+ const info = _.omitBy(smartData, (_value: string, key: string) =>
+ ['scsi_error_counter_log', 'scsi_grown_defect_list'].includes(key)
+ );
+ return {
+ info: info,
+ smart: {
+ scsi_error_counter_log: smartData.scsi_error_counter_log,
+ scsi_grown_defect_list: smartData.scsi_grown_defect_list
+ },
+ device: info.device.name,
+ identifier: info.serial_number
+ };
+ }
+
+ private extractAtaData(smartData: AtaSmartDataV1): SmartDataResult {
+ const info = _.omitBy(smartData, (_value: string, key: string) =>
+ ['ata_smart_attributes', 'ata_smart_selective_self_test_log', 'ata_smart_data'].includes(key)
+ );
+ return {
+ info: info,
+ smart: {
+ attributes: smartData.ata_smart_attributes,
+ data: smartData.ata_smart_data
+ },
+ device: info.device.name,
+ identifier: info.serial_number
+ };
+ }
+
+ private updateData() {
+ this.loading = true;
+
+ if (this.osdId !== null) {
+ this.osdService.getSmartData(this.osdId).subscribe({
+ next: this.fetchData.bind(this),
+ error: (error) => {
+ error.preventDefault();
+ this.error = error;
+ this.loading = false;
+ }
+ });
+ } else if (this.hostname !== null) {
+ this.hostService.getSmartData(this.hostname).subscribe({
+ next: this.fetchData.bind(this),
+ error: (error) => {
+ error.preventDefault();
+ this.error = error;
+ this.loading = false;
+ }
+ });
+ }
+ }
+
+ ngOnInit() {
+ this.smartDataColumns = [
+ { prop: 'id', name: $localize`ID` },
+ { prop: 'name', name: $localize`Name` },
+ { prop: 'raw.value', name: $localize`Raw` },
+ { prop: 'thresh', name: $localize`Threshold` },
+ { prop: 'value', name: $localize`Value` },
+ { prop: 'when_failed', name: $localize`When Failed` },
+ { prop: 'worst', name: $localize`Worst` }
+ ];
+
+ this.scsiSmartDataColumns = [
+ {
+ prop: 'correction_algorithm_invocations',
+ name: $localize`Correction Algorithm Invocations`
+ },
+ {
+ prop: 'errors_corrected_by_eccdelayed',
+ name: $localize`Errors Corrected by ECC (Delayed)`
+ },
+ { prop: 'errors_corrected_by_eccfast', name: $localize`Errors Corrected by ECC (Fast)` },
+ {
+ prop: 'errors_corrected_by_rereads_rewrites',
+ name: $localize`Errors Corrected by Rereads/Rewrites`
+ },
+ { prop: 'gigabytes_processed', name: $localize`Gigabyes Processed` },
+ { prop: 'total_errors_corrected', name: $localize`Total Errors Corrected` },
+ { prop: 'total_uncorrected_errors', name: $localize`Total Errors Uncorrected` }
+ ];
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ this.data = {}; // Clear previous data
+ if (changes.osdId) {
+ this.osdId = changes.osdId.currentValue;
+ } else if (changes.hostname) {
+ this.hostname = changes.hostname.currentValue;
+ }
+ this.updateData();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts
new file mode 100644
index 000000000..74583431c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts
@@ -0,0 +1,87 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule, Routes } from '@angular/router';
+
+import { NgbNavModule, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants';
+import { SharedModule } from '~/app/shared/shared.module';
+import { LoginPasswordFormComponent } from './login-password-form/login-password-form.component';
+import { LoginComponent } from './login/login.component';
+import { RoleDetailsComponent } from './role-details/role-details.component';
+import { RoleFormComponent } from './role-form/role-form.component';
+import { RoleListComponent } from './role-list/role-list.component';
+import { UserFormComponent } from './user-form/user-form.component';
+import { UserListComponent } from './user-list/user-list.component';
+import { UserPasswordFormComponent } from './user-password-form/user-password-form.component';
+import { UserTabsComponent } from './user-tabs/user-tabs.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+ SharedModule,
+ NgbNavModule,
+ NgbPopoverModule,
+ NgxPipeFunctionModule,
+ RouterModule
+ ],
+ declarations: [
+ LoginComponent,
+ LoginPasswordFormComponent,
+ RoleDetailsComponent,
+ RoleFormComponent,
+ RoleListComponent,
+ UserTabsComponent,
+ UserListComponent,
+ UserFormComponent,
+ UserPasswordFormComponent
+ ]
+})
+export class AuthModule {}
+
+const routes: Routes = [
+ { path: '', redirectTo: 'users', pathMatch: 'full' },
+ {
+ path: 'users',
+ data: { breadcrumbs: 'Users' },
+ children: [
+ { path: '', component: UserListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: UserFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:username`,
+ component: UserFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ },
+ {
+ path: 'roles',
+ data: { breadcrumbs: 'Roles' },
+ children: [
+ { path: '', component: RoleListComponent },
+ {
+ path: URLVerbs.CREATE,
+ component: RoleFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
+ {
+ path: `${URLVerbs.EDIT}/:name`,
+ component: RoleFormComponent,
+ data: { breadcrumbs: ActionLabels.EDIT }
+ }
+ ]
+ }
+];
+
+@NgModule({
+ imports: [AuthModule, RouterModule.forChild(routes)]
+})
+export class RoutedAuthModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html
new file mode 100755
index 000000000..2dc30df52
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html
@@ -0,0 +1,95 @@
+<div>
+ <h2 i18n>Please set a new password.</h2>
+ <h4 i18n>You will be redirected to the login page afterwards.</h4>
+ <form #frm="ngForm"
+ [formGroup]="userForm"
+ novalidate>
+
+ <!-- Old password -->
+ <div class="form-group has-feedback">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ placeholder="Old password..."
+ id="oldpassword"
+ formControlName="oldpassword"
+ autocomplete="new-password"
+ autofocus>
+ <span class="input-group-append">
+ <button class="btn btn-outline-light btn-password"
+ cdPasswordButton="oldpassword">
+ </button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('oldpassword', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('oldpassword', frm, 'notmatch')"
+ i18n>The old and new passwords must be different.</span>
+ </div>
+
+ <!-- New password -->
+ <div class="form-group has-feedback">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ placeholder="New password..."
+ id="newpassword"
+ autocomplete="new-password"
+ formControlName="newpassword">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-outline-light btn-password"
+ cdPasswordButton="newpassword">
+ </button>
+ </span>
+ </div>
+ <div class="password-strength-level">
+ <div class="{{ passwordStrengthLevelClass }}"
+ data-toggle="tooltip"
+ title="{{ passwordValuation }}">
+ </div>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('newpassword', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('newpassword', frm, 'notmatch')"
+ i18n>The old and new passwords must be different.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('newpassword', frm, 'passwordPolicy')">
+ {{ passwordValuation }}
+ </span>
+ </div>
+
+ <!-- Confirm new password -->
+ <div class="form-group has-feedback">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ autocomplete="new-password"
+ placeholder="Confirm new password..."
+ id="confirmnewpassword"
+ formControlName="confirmnewpassword">
+ <span class="input-group-append">
+ <button class="btn btn-outline-light btn-password"
+ cdPasswordButton="confirmnewpassword">
+ </button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('confirmnewpassword', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('confirmnewpassword', frm, 'match')"
+ i18n>Password confirmation doesn't match the new password.</span>
+ </div>
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ (backActionEvent)="onCancel()"
+ [form]="userForm"
+ [disabled]="userForm.invalid"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss
new file mode 100755
index 000000000..15addd1e8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss
@@ -0,0 +1,68 @@
+@use 'sass:map';
+@use './src/styles/vendor/variables' as vv;
+
+$dark-secondary: darken(vv.$secondary, 4%);
+
+::ng-deep cd-login-password-form {
+ h4 {
+ margin: 0 0 30px;
+ }
+
+ .form-group {
+ background-color: $dark-secondary;
+ border-left: 4px solid vv.$white;
+
+ &:focus-within {
+ border-left: 4px solid map.get(vv.$theme-colors, 'accent');
+ }
+ }
+
+ .btn-password,
+ .btn-password:focus,
+ .form-control,
+ .form-control:focus {
+ background-color: $dark-secondary;
+ border: 0;
+ box-shadow: none;
+ color: vv.$body-color-bright;
+ filter: none;
+ outline: none;
+ }
+
+ .form-control::placeholder {
+ color: vv.$gray-600;
+ }
+
+ .btn-password:focus {
+ outline-color: vv.$primary;
+ }
+
+ button.btn:not(:first-child) {
+ margin-left: 5px;
+ }
+}
+
+// This will override the colors applied by chrome
+@keyframes autofill {
+ to {
+ background-color: $dark-secondary;
+ color: vv.$body-color-bright;
+ }
+}
+
+input:-webkit-autofill {
+ animation-fill-mode: both;
+ animation-name: autofill;
+ border-radius: 0;
+ box-shadow: 0 0 0 1000px $dark-secondary inset;
+ -webkit-text-fill-color: vv.$body-color-bright;
+ transition-property: none;
+}
+
+.invalid-feedback {
+ padding-left: 9px;
+}
+
+.is-invalid.cd-form-control {
+ border-color: transparent;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts
new file mode 100755
index 000000000..062d076e4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts
@@ -0,0 +1,77 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { AuthService } from '~/app/shared/api/auth.service';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { LoginPasswordFormComponent } from './login-password-form.component';
+
+describe('LoginPasswordFormComponent', () => {
+ let component: LoginPasswordFormComponent;
+ let fixture: ComponentFixture<LoginPasswordFormComponent>;
+ let form: CdFormGroup;
+ let formHelper: FormHelper;
+ let httpTesting: HttpTestingController;
+ let router: Router;
+ let authStorageService: AuthStorageService;
+ let authService: AuthService;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ReactiveFormsModule,
+ ComponentsModule,
+ ToastrModule.forRoot(),
+ SharedModule
+ ],
+ declarations: [LoginPasswordFormComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LoginPasswordFormComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.inject(HttpTestingController);
+ router = TestBed.inject(Router);
+ authStorageService = TestBed.inject(AuthStorageService);
+ authService = TestBed.inject(AuthService);
+ spyOn(router, 'navigate');
+ fixture.detectChanges();
+ form = component.userForm;
+ formHelper = new FormHelper(form);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should submit', () => {
+ spyOn(component, 'onPasswordChange').and.callThrough();
+ spyOn(authService, 'logout');
+ spyOn(authStorageService, 'getUsername').and.returnValue('test1');
+ formHelper.setMultipleValues({
+ oldpassword: 'foo',
+ newpassword: 'bar'
+ });
+ formHelper.setValue('confirmnewpassword', 'bar', true);
+ component.onSubmit();
+ const request = httpTesting.expectOne('api/user/test1/change_password');
+ request.flush({});
+ expect(component.onPasswordChange).toHaveBeenCalled();
+ expect(authService.logout).toHaveBeenCalled();
+ });
+
+ it('should cancel', () => {
+ spyOn(authService, 'logout');
+ component.onCancel();
+ expect(authService.logout).toHaveBeenCalled();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts
new file mode 100755
index 000000000..0e72cca35
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts
@@ -0,0 +1,51 @@
+import { Component } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { AuthService } from '~/app/shared/api/auth.service';
+import { UserService } from '~/app/shared/api/user.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { PasswordPolicyService } from '~/app/shared/services/password-policy.service';
+import { UserPasswordFormComponent } from '../user-password-form/user-password-form.component';
+
+@Component({
+ selector: 'cd-login-password-form',
+ templateUrl: './login-password-form.component.html',
+ styleUrls: ['./login-password-form.component.scss']
+})
+export class LoginPasswordFormComponent extends UserPasswordFormComponent {
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ public notificationService: NotificationService,
+ public userService: UserService,
+ public authStorageService: AuthStorageService,
+ public formBuilder: CdFormBuilder,
+ public router: Router,
+ public passwordPolicyService: PasswordPolicyService,
+ public authService: AuthService
+ ) {
+ super(
+ actionLabels,
+ notificationService,
+ userService,
+ authStorageService,
+ formBuilder,
+ router,
+ passwordPolicyService
+ );
+ }
+
+ onPasswordChange() {
+ // Logout here because changing the password will change the
+ // session token which will finally lead to a 401 when calling
+ // the REST API the next time. The API HTTP interceptor will
+ // then also redirect to the login page immediately.
+ this.authService.logout();
+ }
+
+ onCancel() {
+ this.authService.logout();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html
new file mode 100644
index 000000000..8565c3615
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html
@@ -0,0 +1,64 @@
+<div class="container"
+ *ngIf="isLoginActive">
+ <form name="loginForm"
+ (ngSubmit)="login()"
+ #loginForm="ngForm"
+ novalidate>
+
+ <!-- Username -->
+ <div class="form-group has-feedback d-flex flex-column py-3">
+ <label class="placeholder pl-4"
+ for="username"
+ i18n>Username</label>
+ <input id="username"
+ name="username"
+ [(ngModel)]="model.username"
+ #username="ngModel"
+ type="text"
+ [attr.aria-invalid]="username.invalid"
+ aria-labelledby="username"
+ class="form-control pl-4"
+ required
+ autofocus>
+ <div class="invalid-feedback pl-4"
+ *ngIf="(loginForm.submitted || username.dirty) && username.invalid"
+ i18n>Username is required</div>
+ </div>
+
+ <!-- Password -->
+ <div class="form-group has-feedback"
+ id="password-div">
+ <div class="input-group d-flex flex-nowrap">
+ <div class="d-flex flex-column flex-grow-1 py-3">
+ <label class="placeholder pl-4"
+ for="password"
+ i18n>Password</label>
+ <input id="password"
+ name="password"
+ [(ngModel)]="model.password"
+ #password="ngModel"
+ type="password"
+ [attr.aria-invalid]="password.invalid"
+ aria-labelledby="password"
+ class="form-control pl-4"
+ required>
+ <div class="invalid-feedback pl-4"
+ *ngIf="(loginForm.submitted || password.dirty) && password.invalid"
+ i18n>Password is required</div>
+ </div>
+ <span class="form-group-append">
+ <button type="button"
+ class="btn btn-outline-light btn-password h-100 px-4"
+ cdPasswordButton="password">
+ </button>
+ </span>
+ </div>
+ </div>
+
+ <input type="submit"
+ class="btn btn-accent px-5 py-2"
+ [disabled]="loginForm.invalid"
+ value="Log in"
+ i18n-value>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss
new file mode 100644
index 000000000..0fdc3c6ba
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss
@@ -0,0 +1,54 @@
+@use 'sass:map';
+@use './src/styles/vendor/variables' as vv;
+
+$dark-secondary: darken(vv.$secondary, 4%);
+
+::ng-deep cd-login {
+ .form-group {
+ background-color: $dark-secondary;
+ border-left: 4px solid vv.$white;
+ height: auto;
+ margin-bottom: 2rem;
+
+ &:focus-within {
+ border-left: 4px solid map.get(vv.$theme-colors, 'accent');
+ }
+ }
+
+ .btn-password,
+ .btn-password:focus,
+ .form-control,
+ .form-control:focus {
+ background-color: $dark-secondary;
+ border: 0;
+ box-shadow: none;
+ color: vv.$body-color-bright;
+ filter: none;
+ outline: none;
+ }
+
+ .placeholder {
+ color: vv.$gray-600;
+ }
+
+ .btn-password:focus {
+ outline-color: vv.$primary;
+ }
+}
+
+// This will override the colors applied by chrome
+@keyframes autofill {
+ to {
+ background-color: $dark-secondary;
+ color: vv.$body-color-bright;
+ }
+}
+
+input:-webkit-autofill {
+ animation-fill-mode: both;
+ animation-name: autofill;
+ border-radius: 0;
+ box-shadow: 0 0 0 1000px $dark-secondary inset;
+ -webkit-text-fill-color: vv.$body-color-bright;
+ transition-property: none;
+}
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
new file mode 100644
index 000000000..fc02e9bde
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts
@@ -0,0 +1,58 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+import { AuthService } from '~/app/shared/api/auth.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AuthModule } from '../auth.module';
+import { LoginComponent } from './login.component';
+
+describe('LoginComponent', () => {
+ let component: LoginComponent;
+ let fixture: ComponentFixture<LoginComponent>;
+ let routerNavigateSpy: jasmine.Spy;
+ let authServiceLoginSpy: jasmine.Spy;
+
+ configureTestBed({
+ imports: [RouterTestingModule, HttpClientTestingModule, AuthModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LoginComponent);
+ component = fixture.componentInstance;
+ routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate');
+ routerNavigateSpy.and.returnValue(true);
+ authServiceLoginSpy = spyOn(TestBed.inject(AuthService), 'login');
+ authServiceLoginSpy.and.returnValue(of(null));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should ensure no modal dialogs are opened', () => {
+ component['modalService']['modalsCount'] = 2;
+ component.ngOnInit();
+ expect(component['modalService'].hasOpenModals()).toBeFalsy();
+ });
+
+ it('should not show create cluster wizard if cluster creation was successful', () => {
+ component.postInstalled = true;
+ component.login();
+
+ expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
+ expect(routerNavigateSpy).toHaveBeenCalledWith(['/']);
+ });
+
+ it('should show create cluster wizard if cluster creation was failed', () => {
+ component.postInstalled = false;
+ component.login();
+
+ expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
+ 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
new file mode 100644
index 000000000..a98548f94
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
@@ -0,0 +1,76 @@
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+
+import { AuthService } from '~/app/shared/api/auth.service';
+import { Credentials } from '~/app/shared/models/credentials';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+
+@Component({
+ selector: 'cd-login',
+ templateUrl: './login.component.html',
+ styleUrls: ['./login.component.scss']
+})
+export class LoginComponent implements OnInit {
+ model = new Credentials();
+ isLoginActive = false;
+ returnUrl: string;
+ postInstalled = false;
+
+ constructor(
+ private authService: AuthService,
+ private authStorageService: AuthStorageService,
+ private modalService: ModalService,
+ private route: ActivatedRoute,
+ private router: Router
+ ) {}
+
+ ngOnInit() {
+ if (this.authStorageService.isLoggedIn()) {
+ this.router.navigate(['']);
+ } else {
+ // Make sure all open modal dialogs are closed. This might be
+ // necessary when the logged in user is redirected to the login
+ // page after a 401.
+ this.modalService.dismissAll();
+
+ let token: string = null;
+ if (window.location.hash.indexOf('access_token=') !== -1) {
+ token = window.location.hash.split('access_token=')[1];
+ const uri = window.location.toString();
+ window.history.replaceState({}, document.title, uri.split('?')[0]);
+ }
+ this.authService.check(token).subscribe((login: any) => {
+ if (login.login_url) {
+ this.postInstalled = login.cluster_status === 'POST_INSTALLED';
+ if (login.login_url === '#/login') {
+ this.isLoginActive = true;
+ } else {
+ window.location.replace(login.login_url);
+ }
+ } else {
+ this.authStorageService.set(
+ login.username,
+ login.permissions,
+ login.sso,
+ login.pwdExpirationDate
+ );
+ this.router.navigate(['']);
+ }
+ });
+ }
+ }
+
+ login() {
+ this.authService.login(this.model).subscribe(() => {
+ 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 = '/expand-cluster';
+ }
+ this.router.navigate([url]);
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html
new file mode 100644
index 000000000..ca4b6781b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html
@@ -0,0 +1,11 @@
+<ng-container *ngIf="selection">
+ <cd-table [data]="scopes_permissions"
+ [columns]="columns"
+ columnMode="flex"
+ [toolHeader]="false"
+ [autoReload]="false"
+ [autoSave]="false"
+ [footer]="false"
+ [limit]="0">
+ </cd-table>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss
new file mode 100644
index 000000000..2ec160998
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss
@@ -0,0 +1,9 @@
+@use './src/styles/vendor/variables' as vv;
+
+.fa {
+ font-size: large;
+
+ &.fa-square-o {
+ color: vv.$gray-400;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts
new file mode 100644
index 000000000..b62cd32eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts
@@ -0,0 +1,67 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RoleDetailsComponent } from './role-details.component';
+
+describe('RoleDetailsComponent', () => {
+ let component: RoleDetailsComponent;
+ let fixture: ComponentFixture<RoleDetailsComponent>;
+
+ configureTestBed({
+ imports: [SharedModule, RouterTestingModule, HttpClientTestingModule, NgbNavModule],
+ declarations: [RoleDetailsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RoleDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should create scopes permissions [1/2]', () => {
+ component.scopes = ['log', 'rgw'];
+ component.selection = {
+ description: 'RGW Manager',
+ name: 'rgw-manager',
+ scopes_permissions: {
+ rgw: ['read', 'create', 'update', 'delete']
+ },
+ system: true
+ };
+ expect(component.scopes_permissions.length).toBe(0);
+ component.ngOnChanges();
+ expect(component.scopes_permissions).toEqual([
+ { scope: 'log', read: false, create: false, update: false, delete: false },
+ { scope: 'rgw', read: true, create: true, update: true, delete: true }
+ ]);
+ });
+
+ it('should create scopes permissions [2/2]', () => {
+ component.scopes = ['cephfs', 'log', 'rgw'];
+ component.selection = {
+ description: 'Test',
+ name: 'test',
+ scopes_permissions: {
+ log: ['read', 'update'],
+ rgw: ['read', 'create', 'update']
+ },
+ system: false
+ };
+ expect(component.scopes_permissions.length).toBe(0);
+ component.ngOnChanges();
+ expect(component.scopes_permissions).toEqual([
+ { scope: 'cephfs', read: false, create: false, update: false, delete: false },
+ { scope: 'log', read: true, create: false, update: true, delete: false },
+ { scope: 'rgw', read: true, create: true, update: true, delete: false }
+ ]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts
new file mode 100644
index 000000000..244a7861b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts
@@ -0,0 +1,79 @@
+import { Component, Input, OnChanges, OnInit } from '@angular/core';
+
+import _ from 'lodash';
+
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+
+@Component({
+ selector: 'cd-role-details',
+ templateUrl: './role-details.component.html',
+ styleUrls: ['./role-details.component.scss']
+})
+export class RoleDetailsComponent implements OnChanges, OnInit {
+ @Input()
+ selection: any;
+ @Input()
+ scopes: Array<string>;
+ selectedItem: any;
+
+ columns: CdTableColumn[];
+ scopes_permissions: Array<any> = [];
+
+ ngOnInit() {
+ this.columns = [
+ {
+ prop: 'scope',
+ name: $localize`Scope`,
+ flexGrow: 2
+ },
+ {
+ prop: 'read',
+ name: $localize`Read`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ prop: 'create',
+ name: $localize`Create`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ prop: 'update',
+ name: $localize`Update`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ prop: 'delete',
+ name: $localize`Delete`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ if (this.selection) {
+ this.selectedItem = this.selection;
+ // Build the scopes/permissions data used by the data table.
+ const scopes_permissions: any[] = [];
+ _.each(this.scopes, (scope) => {
+ const scope_permission: any = { read: false, create: false, update: false, delete: false };
+ scope_permission['scope'] = scope;
+ if (scope in this.selectedItem['scopes_permissions']) {
+ _.each(this.selectedItem['scopes_permissions'][scope], (permission) => {
+ scope_permission[permission] = true;
+ });
+ }
+ scopes_permissions.push(scope_permission);
+ });
+ this.scopes_permissions = scopes_permissions;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts
new file mode 100644
index 000000000..4f0a6f11f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts
@@ -0,0 +1,3 @@
+export enum RoleFormMode {
+ editing = 'editing'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html
new file mode 100644
index 000000000..08904c1c2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html
@@ -0,0 +1,121 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="roleForm"
+ #formDir="ngForm"
+ [formGroup]="roleForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+ <div class="card-body">
+
+ <!-- Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': mode !== roleFormMode.editing}"
+ for="name"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ i18n-placeholder
+ placeholder="Name..."
+ id="name"
+ name="name"
+ formControlName="name"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="roleForm.showError('name', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="roleForm.showError('name', formDir, 'notUnique')"
+ i18n>The chosen name is already in use.</span>
+ </div>
+ </div>
+
+ <!-- Description -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="description">Description</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ i18n-placeholder
+ placeholder="Description..."
+ id="description"
+ name="description"
+ formControlName="description">
+ </div>
+ </div>
+
+ <!-- Permissions -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label">Permissions</label>
+ <div class="cd-col-form-input">
+ <cd-table [data]="scopes_permissions"
+ [columns]="columns"
+ columnMode="flex"
+ [toolHeader]="false"
+ [autoReload]="false"
+ [autoSave]="false"
+ [footer]="false"
+ [limit]="0">
+ </cd-table>
+ </div>
+ </div>
+
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="roleForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
+
+<ng-template #cellScopeCheckboxTpl
+ let-column="column"
+ let-row="row"
+ let-value="value">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="scope_{{ row.scope }}"
+ type="checkbox"
+ [checked]="isRowChecked(row.scope)"
+ (change)="onClickCellCheckbox(row.scope, column.prop, $event)">
+ <label class="datatable-permissions-scope-cell-label custom-control-label"
+ for="scope_{{ row.scope }}">{{ value }}</label>
+ </div>
+</ng-template>
+
+<ng-template #cellPermissionCheckboxTpl
+ let-column="column"
+ let-row="row"
+ let-value="value">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ type="checkbox"
+ [checked]="value"
+ [id]="row.scope + '-' + column.prop"
+ (change)="onClickCellCheckbox(row.scope, column.prop, $event)">
+ <label class="custom-control-label"
+ [for]="row.scope + '-' + column.prop"></label>
+ </div>
+</ng-template>
+
+<ng-template #headerPermissionCheckboxTpl
+ let-column="column">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ id="header_{{ column.prop }}"
+ type="checkbox"
+ [checked]="isHeaderChecked(column.prop)"
+ (change)="onClickHeaderCheckbox(column.prop, $event)">
+ <label class="datatable-permissions-header-cell-label custom-control-label"
+ for="header_{{ column.prop }}">{{ column.name }}</label>
+ </div>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss
new file mode 100644
index 000000000..3caafa2ee
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss
@@ -0,0 +1,4 @@
+.datatable-permissions-header-cell-label,
+.datatable-permissions-scope-cell-label {
+ font-weight: bold;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts
new file mode 100644
index 000000000..7552f594b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts
@@ -0,0 +1,222 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RoleService } from '~/app/shared/api/role.service';
+import { ScopeService } from '~/app/shared/api/scope.service';
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { RoleFormComponent } from './role-form.component';
+import { RoleFormModel } from './role-form.model';
+
+describe('RoleFormComponent', () => {
+ let component: RoleFormComponent;
+ let form: CdFormGroup;
+ let fixture: ComponentFixture<RoleFormComponent>;
+ let httpTesting: HttpTestingController;
+ let roleService: RoleService;
+ let router: Router;
+ const setUrl = (url: string) => Object.defineProperty(router, 'url', { value: url });
+
+ @Component({ selector: 'cd-fake', template: '' })
+ class FakeComponent {}
+
+ const routes: Routes = [{ path: 'roles', component: FakeComponent }];
+
+ configureTestBed(
+ {
+ imports: [
+ RouterTestingModule.withRoutes(routes),
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ ToastrModule.forRoot(),
+ SharedModule
+ ],
+ declarations: [RoleFormComponent, FakeComponent]
+ },
+ [LoadingPanelComponent]
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RoleFormComponent);
+ component = fixture.componentInstance;
+ form = component.roleForm;
+ httpTesting = TestBed.inject(HttpTestingController);
+ roleService = TestBed.inject(RoleService);
+ router = TestBed.inject(Router);
+ spyOn(router, 'navigate');
+ fixture.detectChanges();
+ const notify = TestBed.inject(NotificationService);
+ spyOn(notify, 'show');
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(form).toBeTruthy();
+ });
+
+ describe('create mode', () => {
+ let formHelper: FormHelper;
+
+ beforeEach(() => {
+ setUrl('/user-management/roles/add');
+ component.ngOnInit();
+ formHelper = new FormHelper(form);
+ });
+
+ it('should not disable fields', () => {
+ ['name', 'description', 'scopes_permissions'].forEach((key) =>
+ expect(form.get(key).disabled).toBeFalsy()
+ );
+ });
+
+ it('should validate name required', () => {
+ formHelper.expectErrorChange('name', '', 'required');
+ });
+
+ it('should set mode', () => {
+ expect(component.mode).toBeUndefined();
+ });
+
+ it('should submit', () => {
+ const role: RoleFormModel = {
+ name: 'role1',
+ description: 'Role 1',
+ scopes_permissions: { osd: ['read'] }
+ };
+ formHelper.setMultipleValues(role);
+ component.submit();
+ const roleReq = httpTesting.expectOne('api/role');
+ expect(roleReq.request.method).toBe('POST');
+ expect(roleReq.request.body).toEqual(role);
+ roleReq.flush({});
+ expect(router.navigate).toHaveBeenCalledWith(['/user-management/roles']);
+ });
+
+ it('should check all perms for a scope', () => {
+ formHelper.setValue('scopes_permissions', { cephfs: ['read'] });
+ component.onClickCellCheckbox('grafana', 'scope');
+ const scopes_permissions = form.getValue('scopes_permissions');
+ expect(Object.keys(scopes_permissions)).toContain('grafana');
+ expect(scopes_permissions['grafana']).toEqual(['create', 'delete', 'read', 'update']);
+ });
+
+ it('should uncheck all perms for a scope', () => {
+ formHelper.setValue('scopes_permissions', { cephfs: ['read', 'create', 'update', 'delete'] });
+ component.onClickCellCheckbox('cephfs', 'scope');
+ const scopes_permissions = form.getValue('scopes_permissions');
+ expect(Object.keys(scopes_permissions)).not.toContain('cephfs');
+ });
+
+ it('should uncheck all scopes and perms', () => {
+ component.scopes = ['cephfs', 'grafana'];
+ formHelper.setValue('scopes_permissions', {
+ cephfs: ['read', 'delete'],
+ grafana: ['update']
+ });
+ component.onClickHeaderCheckbox('scope', ({
+ target: { checked: false }
+ } as unknown) as Event);
+ const scopes_permissions = form.getValue('scopes_permissions');
+ expect(scopes_permissions).toEqual({});
+ });
+
+ it('should check all scopes and perms', () => {
+ component.scopes = ['cephfs', 'grafana'];
+ formHelper.setValue('scopes_permissions', {
+ cephfs: ['create', 'update'],
+ grafana: ['delete']
+ });
+ component.onClickHeaderCheckbox('scope', ({ target: { checked: true } } as unknown) as Event);
+ const scopes_permissions = form.getValue('scopes_permissions');
+ const keys = Object.keys(scopes_permissions);
+ expect(keys).toEqual(['cephfs', 'grafana']);
+ keys.forEach((key) => {
+ expect(scopes_permissions[key].sort()).toEqual(['create', 'delete', 'read', 'update']);
+ });
+ });
+
+ it('should check if column is checked', () => {
+ component.scopes_permissions = [
+ { scope: 'a', read: true, create: true, update: true, delete: true },
+ { scope: 'b', read: false, create: true, update: false, delete: true }
+ ];
+ expect(component.isRowChecked('a')).toBeTruthy();
+ expect(component.isRowChecked('b')).toBeFalsy();
+ expect(component.isRowChecked('c')).toBeFalsy();
+ });
+
+ it('should check if header is checked', () => {
+ component.scopes_permissions = [
+ { scope: 'a', read: true, create: true, update: false, delete: true },
+ { scope: 'b', read: false, create: true, update: false, delete: true }
+ ];
+ expect(component.isHeaderChecked('read')).toBeFalsy();
+ expect(component.isHeaderChecked('create')).toBeTruthy();
+ expect(component.isHeaderChecked('update')).toBeFalsy();
+ });
+ });
+
+ describe('edit mode', () => {
+ const role: RoleFormModel = {
+ name: 'role1',
+ description: 'Role 1',
+ scopes_permissions: { osd: ['read', 'create'] }
+ };
+ const scopes = ['osd', 'user'];
+ beforeEach(() => {
+ spyOn(roleService, 'get').and.callFake(() => of(role));
+ spyOn(TestBed.inject(ScopeService), 'list').and.callFake(() => of(scopes));
+ setUrl('/user-management/roles/edit/role1');
+ component.ngOnInit();
+ const reqScopes = httpTesting.expectOne('ui-api/scope');
+ expect(reqScopes.request.method).toBe('GET');
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should disable fields if editing', () => {
+ expect(form.get('name').disabled).toBeTruthy();
+ ['description', 'scopes_permissions'].forEach((key) =>
+ expect(form.get(key).disabled).toBeFalsy()
+ );
+ });
+
+ it('should set control values', () => {
+ ['name', 'description', 'scopes_permissions'].forEach((key) =>
+ expect(form.getValue(key)).toBe(role[key])
+ );
+ });
+
+ it('should set mode', () => {
+ expect(component.mode).toBe('editing');
+ });
+
+ it('should submit', () => {
+ component.onClickCellCheckbox('osd', 'update');
+ component.onClickCellCheckbox('osd', 'create');
+ component.onClickCellCheckbox('user', 'read');
+ component.submit();
+ const roleReq = httpTesting.expectOne(`api/role/${role.name}`);
+ expect(roleReq.request.method).toBe('PUT');
+ expect(roleReq.request.body).toEqual({
+ name: 'role1',
+ description: 'Role 1',
+ scopes_permissions: { osd: ['read', 'update'], user: ['read'] }
+ });
+ roleReq.flush({});
+ expect(router.navigate).toHaveBeenCalledWith(['/user-management/roles']);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts
new file mode 100644
index 000000000..21dff1c85
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts
@@ -0,0 +1,315 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import _ from 'lodash';
+import { forkJoin as observableForkJoin } from 'rxjs';
+
+import { RoleService } from '~/app/shared/api/role.service';
+import { ScopeService } from '~/app/shared/api/scope.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { RoleFormMode } from './role-form-mode.enum';
+import { RoleFormModel } from './role-form.model';
+
+@Component({
+ selector: 'cd-role-form',
+ templateUrl: './role-form.component.html',
+ styleUrls: ['./role-form.component.scss']
+})
+export class RoleFormComponent extends CdForm implements OnInit {
+ @ViewChild('headerPermissionCheckboxTpl', { static: true })
+ headerPermissionCheckboxTpl: TemplateRef<any>;
+ @ViewChild('cellScopeCheckboxTpl', { static: true })
+ cellScopeCheckboxTpl: TemplateRef<any>;
+ @ViewChild('cellPermissionCheckboxTpl', { static: true })
+ cellPermissionCheckboxTpl: TemplateRef<any>;
+
+ roleForm: CdFormGroup;
+ response: RoleFormModel;
+
+ columns: CdTableColumn[];
+ scopes: Array<string> = [];
+ scopes_permissions: Array<any> = [];
+
+ roleFormMode = RoleFormMode;
+ mode: RoleFormMode;
+
+ action: string;
+ resource: string;
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private roleService: RoleService,
+ private scopeService: ScopeService,
+ private notificationService: NotificationService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.resource = $localize`role`;
+ this.createForm();
+ this.listenToChanges();
+ }
+
+ createForm() {
+ this.roleForm = new CdFormGroup({
+ name: new FormControl('', {
+ validators: [Validators.required],
+ asyncValidators: [CdValidators.unique(this.roleService.exists, this.roleService)]
+ }),
+ description: new FormControl(''),
+ scopes_permissions: new FormControl({})
+ });
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ prop: 'scope',
+ name: $localize`All`,
+ flexGrow: 2,
+ cellTemplate: this.cellScopeCheckboxTpl,
+ headerTemplate: this.headerPermissionCheckboxTpl
+ },
+ {
+ prop: 'read',
+ name: $localize`Read`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTemplate: this.cellPermissionCheckboxTpl,
+ headerTemplate: this.headerPermissionCheckboxTpl
+ },
+ {
+ prop: 'create',
+ name: $localize`Create`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTemplate: this.cellPermissionCheckboxTpl,
+ headerTemplate: this.headerPermissionCheckboxTpl
+ },
+ {
+ prop: 'update',
+ name: $localize`Update`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTemplate: this.cellPermissionCheckboxTpl,
+ headerTemplate: this.headerPermissionCheckboxTpl
+ },
+ {
+ prop: 'delete',
+ name: $localize`Delete`,
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTemplate: this.cellPermissionCheckboxTpl,
+ headerTemplate: this.headerPermissionCheckboxTpl
+ }
+ ];
+ if (this.router.url.startsWith('/user-management/roles/edit')) {
+ this.mode = this.roleFormMode.editing;
+ this.action = this.actionLabels.EDIT;
+ } else {
+ this.action = this.actionLabels.CREATE;
+ }
+ if (this.mode === this.roleFormMode.editing) {
+ this.initEdit();
+ } else {
+ this.initCreate();
+ }
+ }
+
+ initCreate() {
+ // Load the scopes and initialize the default scopes/permissions data.
+ this.scopeService.list().subscribe((scopes: Array<string>) => {
+ this.scopes = scopes;
+ this.roleForm.get('scopes_permissions').setValue({});
+
+ this.loadingReady();
+ });
+ }
+
+ initEdit() {
+ // Disable the 'Name' input field.
+ this.roleForm.get('name').disable();
+ // Load the scopes and the role data.
+ this.route.params.subscribe((params: { name: string }) => {
+ const observables = [];
+ observables.push(this.scopeService.list());
+ observables.push(this.roleService.get(params.name));
+ observableForkJoin(observables).subscribe((resp: any[]) => {
+ this.scopes = resp[0];
+ ['name', 'description', 'scopes_permissions'].forEach((key) =>
+ this.roleForm.get(key).setValue(resp[1][key])
+ );
+
+ this.loadingReady();
+ });
+ });
+ }
+
+ listenToChanges() {
+ // Create/Update the data which is used by the data table to display the
+ // scopes/permissions every time the form field value has been changed.
+ this.roleForm.get('scopes_permissions').valueChanges.subscribe((value) => {
+ const scopes_permissions: any[] = [];
+ _.each(this.scopes, (scope) => {
+ // Set the defaults values.
+ const scope_permission: any = { read: false, create: false, update: false, delete: false };
+ scope_permission['scope'] = scope;
+ // Apply settings from the given value if they exist.
+ if (scope in value) {
+ _.each(value[scope], (permission) => {
+ scope_permission[permission] = true;
+ });
+ }
+ scopes_permissions.push(scope_permission);
+ });
+ this.scopes_permissions = scopes_permissions;
+ });
+ }
+
+ /**
+ * Checks if the specified row checkbox needs to be rendered as checked.
+ * @param {string} scope The scope to be checked, e.g. 'cephfs', 'grafana',
+ * 'osd', 'pool' ...
+ * @return Returns true if all permissions (read, create, update, delete)
+ * are checked for the specified scope, otherwise false.
+ */
+ isRowChecked(scope: string) {
+ const scope_permission = _.find(this.scopes_permissions, (o) => {
+ return o['scope'] === scope;
+ });
+ if (_.isUndefined(scope_permission)) {
+ return false;
+ }
+ return (
+ scope_permission['read'] &&
+ scope_permission['create'] &&
+ scope_permission['update'] &&
+ scope_permission['delete']
+ );
+ }
+
+ /**
+ * Checks if the specified header checkbox needs to be rendered as checked.
+ * @param {string} property The property/permission (read, create,
+ * update, delete) to be checked. If 'scope' is given, all permissions
+ * are checked.
+ * @return Returns true if specified property/permission is selected
+ * for all scopes, otherwise false.
+ */
+ isHeaderChecked(property: string) {
+ let permissions = [property];
+ if ('scope' === property) {
+ permissions = ['read', 'create', 'update', 'delete'];
+ }
+ return permissions.every((permission) => {
+ return this.scopes_permissions.every((scope_permission) => {
+ return scope_permission[permission];
+ });
+ });
+ }
+
+ onClickCellCheckbox(scope: string, property: string, event: any = null) {
+ // Use a copy of the form field data to do not trigger the redrawing of the
+ // data table with every change.
+ const scopes_permissions = _.cloneDeep(this.roleForm.getValue('scopes_permissions'));
+ let permissions = [property];
+ if ('scope' === property) {
+ permissions = ['read', 'create', 'update', 'delete'];
+ }
+ if (!(scope in scopes_permissions)) {
+ scopes_permissions[scope] = [];
+ }
+ // Add or remove the given permission(s) depending on the click event or if no
+ // click event is given then add/remove them if they are absent/exist.
+ if (
+ (event && event.target['checked']) ||
+ !_.isEqual(permissions.sort(), _.intersection(scopes_permissions[scope], permissions).sort())
+ ) {
+ scopes_permissions[scope] = _.union(scopes_permissions[scope], permissions);
+ } else {
+ scopes_permissions[scope] = _.difference(scopes_permissions[scope], permissions);
+ if (_.isEmpty(scopes_permissions[scope])) {
+ _.unset(scopes_permissions, scope);
+ }
+ }
+ this.roleForm.get('scopes_permissions').setValue(scopes_permissions);
+ }
+
+ onClickHeaderCheckbox(property: 'scope' | 'read' | 'create' | 'update' | 'delete', event: any) {
+ // Use a copy of the form field data to do not trigger the redrawing of the
+ // data table with every change.
+ const scopes_permissions = _.cloneDeep(this.roleForm.getValue('scopes_permissions'));
+ let permissions = [property];
+ if ('scope' === property) {
+ permissions = ['read', 'create', 'update', 'delete'];
+ }
+ _.each(permissions, (permission) => {
+ _.each(this.scopes, (scope) => {
+ if (event.target['checked']) {
+ scopes_permissions[scope] = _.union(scopes_permissions[scope], [permission]);
+ } else {
+ scopes_permissions[scope] = _.difference(scopes_permissions[scope], [permission]);
+ if (_.isEmpty(scopes_permissions[scope])) {
+ _.unset(scopes_permissions, scope);
+ }
+ }
+ });
+ });
+ this.roleForm.get('scopes_permissions').setValue(scopes_permissions);
+ }
+
+ getRequest(): RoleFormModel {
+ const roleFormModel = new RoleFormModel();
+ ['name', 'description', 'scopes_permissions'].forEach(
+ (key) => (roleFormModel[key] = this.roleForm.get(key).value)
+ );
+ return roleFormModel;
+ }
+
+ createAction() {
+ const roleFormModel = this.getRequest();
+ this.roleService.create(roleFormModel).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created role '${roleFormModel.name}'`
+ );
+ this.router.navigate(['/user-management/roles']);
+ },
+ () => {
+ this.roleForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ editAction() {
+ const roleFormModel = this.getRequest();
+ this.roleService.update(roleFormModel).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated role '${roleFormModel.name}'`
+ );
+ this.router.navigate(['/user-management/roles']);
+ },
+ () => {
+ this.roleForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ submit() {
+ if (this.mode === this.roleFormMode.editing) {
+ this.editAction();
+ } else {
+ this.createAction();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts
new file mode 100644
index 000000000..74a7323be
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts
@@ -0,0 +1,5 @@
+export class RoleFormModel {
+ name: string;
+ description: string;
+ scopes_permissions: any;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html
new file mode 100644
index 000000000..6b8a5d73e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html
@@ -0,0 +1,21 @@
+<cd-user-tabs></cd-user-tabs>
+
+<cd-table [data]="roles"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="name"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (fetchData)="getRoles()"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-role-details cdTableDetail
+ [selection]="expandedRow"
+ [scopes]="scopes">
+ </cd-role-details>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts
new file mode 100644
index 000000000..373e37b9d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts
@@ -0,0 +1,83 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { RoleDetailsComponent } from '../role-details/role-details.component';
+import { UserTabsComponent } from '../user-tabs/user-tabs.component';
+import { RoleListComponent } from './role-list.component';
+
+describe('RoleListComponent', () => {
+ let component: RoleListComponent;
+ let fixture: ComponentFixture<RoleListComponent>;
+
+ configureTestBed({
+ declarations: [RoleListComponent, RoleDetailsComponent, UserTabsComponent],
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbNavModule,
+ RouterTestingModule,
+ HttpClientTestingModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RoleListComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Clone', 'Edit', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Clone', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Clone', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Delete', single: 'Delete', no: 'Create' }
+ },
+ create: {
+ actions: ['Create', 'Clone'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Delete'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts
new file mode 100644
index 000000000..83dcd69fa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts
@@ -0,0 +1,169 @@
+import { Component, OnInit } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { forkJoin } from 'rxjs';
+
+import { RoleService } from '~/app/shared/api/role.service';
+import { ScopeService } from '~/app/shared/api/scope.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+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 { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { EmptyPipe } from '~/app/shared/pipes/empty.pipe';
+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 { URLBuilderService } from '~/app/shared/services/url-builder.service';
+
+const BASE_URL = 'user-management/roles';
+
+@Component({
+ selector: 'cd-role-list',
+ templateUrl: './role-list.component.html',
+ styleUrls: ['./role-list.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class RoleListComponent extends ListWithDetails implements OnInit {
+ permission: Permission;
+ tableActions: CdTableAction[];
+ columns: CdTableColumn[];
+ roles: Array<any>;
+ scopes: Array<string>;
+ selection = new CdTableSelection();
+
+ modalRef: NgbModalRef;
+
+ constructor(
+ private roleService: RoleService,
+ private scopeService: ScopeService,
+ private emptyPipe: EmptyPipe,
+ private authStorageService: AuthStorageService,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ private urlBuilder: URLBuilderService,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().user;
+ const addAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ name: this.actionLabels.CREATE
+ };
+ const cloneAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.clone,
+ name: this.actionLabels.CLONE,
+ disable: () => !this.selection.hasSingleSelection,
+ click: () => this.cloneRole()
+ };
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ disable: () => !this.selection.hasSingleSelection || this.selection.first().system,
+ routerLink: () =>
+ this.selection.first() && this.urlBuilder.getEdit(this.selection.first().name),
+ name: this.actionLabels.EDIT
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ disable: () => !this.selection.hasSingleSelection || this.selection.first().system,
+ click: () => this.deleteRoleModal(),
+ name: this.actionLabels.DELETE
+ };
+ this.tableActions = [addAction, cloneAction, editAction, deleteAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 3
+ },
+ {
+ name: $localize`Description`,
+ prop: 'description',
+ flexGrow: 5,
+ pipe: this.emptyPipe
+ },
+ {
+ name: $localize`System Role`,
+ prop: 'system',
+ cellClass: 'text-center',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.checkIcon
+ }
+ ];
+ }
+
+ getRoles() {
+ forkJoin([this.roleService.list(), this.scopeService.list()]).subscribe(
+ (data: [Array<any>, Array<string>]) => {
+ this.roles = data[0];
+ this.scopes = data[1];
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteRole(role: string) {
+ this.roleService.delete(role).subscribe(
+ () => {
+ this.getRoles();
+ this.modalRef.close();
+ this.notificationService.show(NotificationType.success, $localize`Deleted role '${role}'`);
+ },
+ () => {
+ this.modalRef.componentInstance.stopLoadingSpinner();
+ }
+ );
+ }
+
+ deleteRoleModal() {
+ const name = this.selection.first().name;
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'Role',
+ itemNames: [name],
+ submitAction: () => this.deleteRole(name)
+ });
+ }
+
+ cloneRole() {
+ const name = this.selection.first().name;
+ this.modalRef = this.modalService.show(FormModalComponent, {
+ fields: [
+ {
+ type: 'text',
+ name: 'newName',
+ value: `${name}_clone`,
+ label: $localize`New name`,
+ required: true
+ }
+ ],
+ titleText: $localize`Clone Role`,
+ submitButtonText: $localize`Clone Role`,
+ onSubmit: (values: object) => {
+ this.roleService.clone(name, values['newName']).subscribe(() => {
+ this.getRoles();
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Cloned role '${values['newName']}' from '${name}'`
+ );
+ });
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts
new file mode 100644
index 000000000..8cae7d15f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts
@@ -0,0 +1,3 @@
+export enum UserFormMode {
+ editing = 'editing'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts
new file mode 100644
index 000000000..2d323b04e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts
@@ -0,0 +1,14 @@
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+
+export class UserFormRoleModel implements SelectOption {
+ name: string;
+ description: string;
+ selected = false;
+ scopes_permissions: object;
+ enabled = true;
+
+ constructor(name: string, description: string) {
+ this.name = name;
+ this.description = description;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html
new file mode 100644
index 000000000..df97face8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html
@@ -0,0 +1,263 @@
+<div class="cd-col-form"
+ *cdFormLoading="loading">
+ <form name="userForm"
+ #formDir="ngForm"
+ [formGroup]="userForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+ <div class="card-body">
+
+ <!-- Username -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': mode !== userFormMode.editing}"
+ for="username"
+ i18n>Username</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Username..."
+ id="username"
+ name="username"
+ formControlName="username"
+ autocomplete="off"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('username', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('username', formDir, 'notUnique')"
+ i18n>The username already exists.</span>
+ </div>
+ </div>
+
+ <!-- Password -->
+ <div class="form-group row"
+ *ngIf="!authStorageService.isSSO()">
+ <label class="cd-col-form-label"
+ for="password">
+ <ng-container i18n>Password</ng-container>
+ <cd-helper *ngIf="passwordPolicyHelpText.length > 0"
+ class="text-pre-wrap"
+ html="{{ passwordPolicyHelpText }}">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ placeholder="Password..."
+ id="password"
+ name="password"
+ autocomplete="new-password"
+ formControlName="password">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="password">
+ </button>
+ </span>
+ </div>
+ <div class="password-strength-level">
+ <div class="{{ passwordStrengthLevelClass }}"
+ data-toggle="tooltip"
+ title="{{ passwordValuation }}">
+ </div>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('password', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('password', formDir, 'passwordPolicy')">
+ {{ passwordValuation }}
+ </span>
+ </div>
+ </div>
+
+ <!-- Confirm password -->
+ <div class="form-group row"
+ *ngIf="!authStorageService.isSSO()">
+ <label i18n
+ class="cd-col-form-label"
+ for="confirmpassword">Confirm password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ placeholder="Confirm password..."
+ id="confirmpassword"
+ name="confirmpassword"
+ autocomplete="new-password"
+ formControlName="confirmpassword">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="confirmpassword">
+ </button>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('confirmpassword', formDir, 'match')"
+ i18n>Password confirmation doesn't match the password.</span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('confirmpassword', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+
+ <!-- Password expiration date -->
+ <div class="form-group row"
+ *ngIf="!authStorageService.isSSO()">
+ <label class="cd-col-form-label"
+ [ngClass]="{'required': pwdExpirationSettings.pwdExpirationSpan > 0}"
+ for="pwdExpirationDate">
+ <ng-container i18n>Password expiration date</ng-container>
+ <cd-helper class="text-pre-wrap"
+ *ngIf="pwdExpirationSettings.pwdExpirationSpan == 0">
+ <p>
+ The Dashboard setting defining the expiration interval of
+ passwords is currently set to <strong>0</strong>. This means
+ if a date is set, the user password will only expire once.
+ </p>
+ <p>
+ Consider configuring the Dashboard setting
+ <a routerLink="/mgr-modules/edit/dashboard"
+ class="alert-link">USER_PWD_EXPIRATION_SPAN</a>
+ in order to let passwords expire periodically.
+ </p>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ i18n-placeholder
+ placeholder="Password expiration date..."
+ id="pwdExpirationDate"
+ name="pwdExpirationDate"
+ formControlName="pwdExpirationDate"
+ [ngbPopover]="popContent"
+ triggers="manual"
+ #p="ngbPopover"
+ (click)="p.open()"
+ (keypress)="p.close()">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ (click)="clearExpirationDate()">
+ <i class="icon-prepend {{ icons.destroy }}"></i>
+ </button>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('pwdExpirationDate', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Name -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="name">Full name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Full name..."
+ id="name"
+ name="name"
+ formControlName="name">
+ </div>
+ </div>
+
+ <!-- Email -->
+ <div class="form-group row">
+ <label i18n
+ class="cd-col-form-label"
+ for="email">Email</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="email"
+ placeholder="Email..."
+ id="email"
+ name="email"
+ formControlName="email">
+
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('email', formDir, 'email')"
+ i18n>Invalid email.</span>
+ </div>
+ </div>
+
+ <!-- Roles -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ i18n>Roles</label>
+ <div class="cd-col-form-input">
+ <span class="no-border full-height"
+ *ngIf="allRoles">
+ <cd-select-badges [data]="userForm.controls.roles.value"
+ [options]="allRoles"
+ [messages]="messages"></cd-select-badges>
+ </span>
+ </div>
+ </div>
+
+ <!-- Enabled -->
+ <div class="form-group row"
+ *ngIf="!isCurrentUser()">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="enabled"
+ name="enabled"
+ formControlName="enabled">
+ <label class="custom-control-label"
+ for="enabled"
+ i18n>Enabled</label>
+ </div>
+ </div>
+ </div>
+
+ <!-- Force change password -->
+ <div class="form-group row"
+ *ngIf="!isCurrentUser() && !authStorageService.isSSO()">
+ <div class="cd-col-form-offset">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="pwdUpdateRequired"
+ name="pwdUpdateRequired"
+ formControlName="pwdUpdateRequired">
+ <label class="custom-control-label"
+ for="pwdUpdateRequired"
+ i18n>User must change password at next logon</label>
+ </div>
+ </div>
+ </div>
+
+ </div>
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="userForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
+
+<ng-template #removeSelfUserReadUpdatePermissionTpl>
+ <p><strong i18n>You are about to remove "user read / update" permissions from your own user.</strong></p>
+ <br>
+ <p i18n>If you continue, you will no longer be able to add or remove roles from any user.</p>
+
+ <ng-container i18n>Are you sure you want to continue?</ng-container>
+</ng-template>
+
+<ng-template #popContent>
+ <cd-date-time-picker [control]="userForm.get('pwdExpirationDate')"
+ [hasTime]="false"></cd-date-time-picker>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts
new file mode 100644
index 000000000..4f95ac1e2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts
@@ -0,0 +1,258 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { RoleService } from '~/app/shared/api/role.service';
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { UserService } from '~/app/shared/api/user.service';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+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 { PasswordPolicyService } from '~/app/shared/services/password-policy.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { UserFormComponent } from './user-form.component';
+import { UserFormModel } from './user-form.model';
+
+describe('UserFormComponent', () => {
+ let component: UserFormComponent;
+ let form: CdFormGroup;
+ let fixture: ComponentFixture<UserFormComponent>;
+ let httpTesting: HttpTestingController;
+ let userService: UserService;
+ let modalService: ModalService;
+ let router: Router;
+ let formHelper: FormHelper;
+
+ const setUrl = (url: string) => Object.defineProperty(router, 'url', { value: url });
+
+ @Component({ selector: 'cd-fake', template: '' })
+ class FakeComponent {}
+
+ const routes: Routes = [
+ { path: 'login', component: FakeComponent },
+ { path: 'users', component: FakeComponent }
+ ];
+
+ configureTestBed(
+ {
+ imports: [
+ RouterTestingModule.withRoutes(routes),
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ ComponentsModule,
+ ToastrModule.forRoot(),
+ SharedModule,
+ NgbPopoverModule
+ ],
+ declarations: [UserFormComponent, FakeComponent]
+ },
+ [LoadingPanelComponent]
+ );
+
+ beforeEach(() => {
+ spyOn(TestBed.inject(PasswordPolicyService), 'getHelpText').and.callFake(() => of(''));
+ fixture = TestBed.createComponent(UserFormComponent);
+ component = fixture.componentInstance;
+ form = component.userForm;
+ httpTesting = TestBed.inject(HttpTestingController);
+ userService = TestBed.inject(UserService);
+ modalService = TestBed.inject(ModalService);
+ router = TestBed.inject(Router);
+ spyOn(router, 'navigate');
+ fixture.detectChanges();
+ const notify = TestBed.inject(NotificationService);
+ spyOn(notify, 'show');
+ formHelper = new FormHelper(form);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(form).toBeTruthy();
+ });
+
+ describe('create mode', () => {
+ beforeEach(() => {
+ setUrl('/user-management/users/add');
+ component.ngOnInit();
+ });
+
+ it('should not disable fields', () => {
+ [
+ 'username',
+ 'name',
+ 'password',
+ 'confirmpassword',
+ 'email',
+ 'roles',
+ 'pwdExpirationDate'
+ ].forEach((key) => expect(form.get(key).disabled).toBeFalsy());
+ });
+
+ it('should validate username required', () => {
+ formHelper.expectErrorChange('username', '', 'required');
+ formHelper.expectValidChange('username', 'user1');
+ });
+
+ it('should validate password match', () => {
+ formHelper.setValue('password', 'aaa');
+ formHelper.expectErrorChange('confirmpassword', 'bbb', 'match');
+ formHelper.expectValidChange('confirmpassword', 'aaa');
+ });
+
+ it('should validate email', () => {
+ formHelper.expectErrorChange('email', 'aaa', 'email');
+ });
+
+ it('should set mode', () => {
+ expect(component.mode).toBeUndefined();
+ });
+
+ it('should submit', () => {
+ const user: UserFormModel = {
+ username: 'user0',
+ password: 'pass0',
+ name: 'User 0',
+ email: 'user0@email.com',
+ roles: ['administrator'],
+ enabled: true,
+ pwdExpirationDate: undefined,
+ pwdUpdateRequired: true
+ };
+ formHelper.setMultipleValues(user);
+ formHelper.setValue('confirmpassword', user.password);
+ component.submit();
+ const userReq = httpTesting.expectOne('api/user');
+ expect(userReq.request.method).toBe('POST');
+ expect(userReq.request.body).toEqual(user);
+ userReq.flush({});
+ expect(router.navigate).toHaveBeenCalledWith(['/user-management/users']);
+ });
+ });
+
+ describe('edit mode', () => {
+ const user: UserFormModel = {
+ username: 'user1',
+ password: undefined,
+ name: 'User 1',
+ email: 'user1@email.com',
+ roles: ['administrator'],
+ enabled: true,
+ pwdExpirationDate: undefined,
+ pwdUpdateRequired: true
+ };
+ const roles = [
+ {
+ name: 'administrator',
+ description: 'Administrator',
+ scopes_permissions: {
+ user: ['create', 'delete', 'read', 'update']
+ }
+ },
+ {
+ name: 'read-only',
+ description: 'Read-Only',
+ scopes_permissions: {
+ user: ['read']
+ }
+ },
+ {
+ name: 'user-manager',
+ description: 'User Manager',
+ scopes_permissions: {
+ user: ['create', 'delete', 'read', 'update']
+ }
+ }
+ ];
+
+ beforeEach(() => {
+ spyOn(userService, 'get').and.callFake(() => of(user));
+ spyOn(TestBed.inject(RoleService), 'list').and.callFake(() => of(roles));
+ setUrl('/user-management/users/edit/user1');
+ spyOn(TestBed.inject(SettingsService), 'getStandardSettings').and.callFake(() =>
+ of({
+ user_pwd_expiration_warning_1: 10,
+ user_pwd_expiration_warning_2: 5,
+ user_pwd_expiration_span: 90
+ })
+ );
+ component.ngOnInit();
+ const req = httpTesting.expectOne('api/role');
+ expect(req.request.method).toBe('GET');
+ req.flush(roles);
+ httpTesting.expectOne('ui-api/standard_settings');
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should disable fields if editing', () => {
+ expect(form.get('username').disabled).toBeTruthy();
+ ['name', 'password', 'confirmpassword', 'email', 'roles'].forEach((key) =>
+ expect(form.get(key).disabled).toBeFalsy()
+ );
+ });
+
+ it('should set control values', () => {
+ ['username', 'name', 'email', 'roles'].forEach((key) =>
+ expect(form.getValue(key)).toBe(user[key])
+ );
+ ['password', 'confirmpassword'].forEach((key) => expect(form.getValue(key)).toBeFalsy());
+ });
+
+ it('should set mode', () => {
+ expect(component.mode).toBe('editing');
+ });
+
+ it('should alert if user is removing needed role permission', () => {
+ spyOn(TestBed.inject(AuthStorageService), 'getUsername').and.callFake(() => user.username);
+ let modalBodyTpl = null;
+ spyOn(modalService, 'show').and.callFake((_content, initialState) => {
+ modalBodyTpl = initialState.bodyTpl;
+ });
+ formHelper.setValue('roles', ['read-only']);
+ component.submit();
+ expect(modalBodyTpl).toEqual(component.removeSelfUserReadUpdatePermissionTpl);
+ });
+
+ it('should logout if current user roles have been changed', () => {
+ spyOn(TestBed.inject(AuthStorageService), 'getUsername').and.callFake(() => user.username);
+ formHelper.setValue('roles', ['user-manager']);
+ component.submit();
+ const userReq = httpTesting.expectOne(`api/user/${user.username}`);
+ expect(userReq.request.method).toBe('PUT');
+ userReq.flush({});
+ const authReq = httpTesting.expectOne('api/auth/logout');
+ expect(authReq.request.method).toBe('POST');
+ });
+
+ it('should submit', () => {
+ spyOn(TestBed.inject(AuthStorageService), 'getUsername').and.callFake(() => user.username);
+ component.submit();
+ const userReq = httpTesting.expectOne(`api/user/${user.username}`);
+ expect(userReq.request.method).toBe('PUT');
+ expect(userReq.request.body).toEqual({
+ username: 'user1',
+ password: '',
+ pwdUpdateRequired: true,
+ name: 'User 1',
+ email: 'user1@email.com',
+ roles: ['administrator'],
+ enabled: true
+ });
+ userReq.flush({});
+ expect(router.navigate).toHaveBeenCalledWith(['/user-management/users']);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
new file mode 100644
index 000000000..1a0ddf35c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
@@ -0,0 +1,305 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import moment from 'moment';
+import { forkJoin as observableForkJoin } from 'rxjs';
+
+import { AuthService } from '~/app/shared/api/auth.service';
+import { RoleService } from '~/app/shared/api/role.service';
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { UserService } from '~/app/shared/api/user.service';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdPwdExpirationSettings } from '~/app/shared/models/cd-pwd-expiration-settings';
+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 { PasswordPolicyService } from '~/app/shared/services/password-policy.service';
+import { UserFormMode } from './user-form-mode.enum';
+import { UserFormRoleModel } from './user-form-role.model';
+import { UserFormModel } from './user-form.model';
+
+@Component({
+ selector: 'cd-user-form',
+ templateUrl: './user-form.component.html',
+ styleUrls: ['./user-form.component.scss']
+})
+export class UserFormComponent extends CdForm implements OnInit {
+ @ViewChild('removeSelfUserReadUpdatePermissionTpl', { static: true })
+ removeSelfUserReadUpdatePermissionTpl: TemplateRef<any>;
+
+ modalRef: NgbModalRef;
+
+ userForm: CdFormGroup;
+ response: UserFormModel;
+
+ userFormMode = UserFormMode;
+ mode: UserFormMode;
+ allRoles: Array<UserFormRoleModel>;
+ messages = new SelectMessages({ empty: $localize`There are no roles.` });
+ action: string;
+ resource: string;
+ passwordPolicyHelpText = '';
+ passwordStrengthLevelClass: string;
+ passwordValuation: string;
+ icons = Icons;
+ pwdExpirationSettings: CdPwdExpirationSettings;
+ pwdExpirationFormat = 'YYYY-MM-DD';
+
+ constructor(
+ private authService: AuthService,
+ private authStorageService: AuthStorageService,
+ private route: ActivatedRoute,
+ public router: Router,
+ private modalService: ModalService,
+ private roleService: RoleService,
+ private userService: UserService,
+ private notificationService: NotificationService,
+ public actionLabels: ActionLabelsI18n,
+ private passwordPolicyService: PasswordPolicyService,
+ private formBuilder: CdFormBuilder,
+ private settingsService: SettingsService
+ ) {
+ super();
+ this.resource = $localize`user`;
+ this.createForm();
+ this.messages = new SelectMessages({ empty: $localize`There are no roles.` });
+ }
+
+ createForm() {
+ this.passwordPolicyService.getHelpText().subscribe((helpText: string) => {
+ this.passwordPolicyHelpText = helpText;
+ });
+ this.userForm = this.formBuilder.group(
+ {
+ username: [
+ '',
+ [Validators.required],
+ [CdValidators.unique(this.userService.validateUserName, this.userService)]
+ ],
+ name: [''],
+ password: [
+ '',
+ [],
+ [
+ CdValidators.passwordPolicy(
+ this.userService,
+ () => this.userForm.getValue('username'),
+ (_valid: boolean, credits: number, valuation: string) => {
+ this.passwordStrengthLevelClass = this.passwordPolicyService.mapCreditsToCssClass(
+ credits
+ );
+ this.passwordValuation = _.defaultTo(valuation, '');
+ }
+ )
+ ]
+ ],
+ confirmpassword: [''],
+ pwdExpirationDate: [undefined],
+ email: ['', [CdValidators.email]],
+ roles: [[]],
+ enabled: [true, [Validators.required]],
+ pwdUpdateRequired: [true]
+ },
+ {
+ validators: [CdValidators.match('password', 'confirmpassword')]
+ }
+ );
+ }
+
+ ngOnInit() {
+ if (this.router.url.startsWith('/user-management/users/edit')) {
+ this.mode = this.userFormMode.editing;
+ this.action = this.actionLabels.EDIT;
+ } else {
+ this.action = this.actionLabels.CREATE;
+ }
+
+ const observables = [this.roleService.list(), this.settingsService.getStandardSettings()];
+ observableForkJoin(observables).subscribe(
+ (result: [UserFormRoleModel[], CdPwdExpirationSettings]) => {
+ this.allRoles = _.map(result[0], (role) => {
+ role.enabled = true;
+ return role;
+ });
+ this.pwdExpirationSettings = new CdPwdExpirationSettings(result[1]);
+
+ if (this.mode === this.userFormMode.editing) {
+ this.initEdit();
+ } else {
+ if (this.pwdExpirationSettings.pwdExpirationSpan > 0) {
+ const pwdExpirationDateField = this.userForm.get('pwdExpirationDate');
+ const expirationDate = moment();
+ expirationDate.add(this.pwdExpirationSettings.pwdExpirationSpan, 'day');
+ pwdExpirationDateField.setValue(expirationDate.format(this.pwdExpirationFormat));
+ pwdExpirationDateField.setValidators([Validators.required]);
+ }
+
+ this.loadingReady();
+ }
+ }
+ );
+ }
+
+ initEdit() {
+ this.disableForEdit();
+ this.route.params.subscribe((params: { username: string }) => {
+ const username = params.username;
+ this.userService.get(username).subscribe((userFormModel: UserFormModel) => {
+ this.response = _.cloneDeep(userFormModel);
+ this.setResponse(userFormModel);
+
+ this.loadingReady();
+ });
+ });
+ }
+
+ disableForEdit() {
+ this.userForm.get('username').disable();
+ }
+
+ setResponse(response: UserFormModel) {
+ ['username', 'name', 'email', 'roles', 'enabled', 'pwdUpdateRequired'].forEach((key) =>
+ this.userForm.get(key).setValue(response[key])
+ );
+ const expirationDate = response['pwdExpirationDate'];
+ if (expirationDate) {
+ this.userForm
+ .get('pwdExpirationDate')
+ .setValue(moment(expirationDate * 1000).format(this.pwdExpirationFormat));
+ }
+ }
+
+ getRequest(): UserFormModel {
+ const userFormModel = new UserFormModel();
+ ['username', 'password', 'name', 'email', 'roles', 'enabled', 'pwdUpdateRequired'].forEach(
+ (key) => (userFormModel[key] = this.userForm.get(key).value)
+ );
+ const expirationDate = this.userForm.get('pwdExpirationDate').value;
+ if (expirationDate) {
+ const mom = moment(expirationDate, this.pwdExpirationFormat);
+ if (
+ this.mode !== this.userFormMode.editing ||
+ this.response.pwdExpirationDate !== mom.unix()
+ ) {
+ mom.set({ hour: 23, minute: 59, second: 59 });
+ }
+ userFormModel['pwdExpirationDate'] = mom.unix();
+ }
+ return userFormModel;
+ }
+
+ createAction() {
+ const userFormModel = this.getRequest();
+ this.userService.create(userFormModel).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created user '${userFormModel.username}'`
+ );
+ this.router.navigate(['/user-management/users']);
+ },
+ () => {
+ this.userForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ editAction() {
+ if (this.isUserRemovingNeededRolePermissions()) {
+ const initialState = {
+ titleText: $localize`Update user`,
+ buttonText: $localize`Continue`,
+ bodyTpl: this.removeSelfUserReadUpdatePermissionTpl,
+ onSubmit: () => {
+ this.modalRef.close();
+ this.doEditAction();
+ },
+ onCancel: () => {
+ this.userForm.setErrors({ cdSubmitButton: true });
+ this.userForm.get('roles').reset(this.userForm.get('roles').value);
+ }
+ };
+ this.modalRef = this.modalService.show(ConfirmationModalComponent, initialState);
+ } else {
+ this.doEditAction();
+ }
+ }
+
+ public isCurrentUser(): boolean {
+ return this.authStorageService.getUsername() === this.userForm.getValue('username');
+ }
+
+ private isUserChangingRoles(): boolean {
+ const isCurrentUser = this.isCurrentUser();
+ return (
+ isCurrentUser &&
+ this.response &&
+ !_.isEqual(this.response.roles, this.userForm.getValue('roles'))
+ );
+ }
+
+ private isUserRemovingNeededRolePermissions(): boolean {
+ const isCurrentUser = this.isCurrentUser();
+ return isCurrentUser && !this.hasUserReadUpdatePermissions(this.userForm.getValue('roles'));
+ }
+
+ private hasUserReadUpdatePermissions(roles: Array<string> = []) {
+ for (const role of this.allRoles) {
+ if (roles.indexOf(role.name) !== -1 && role.scopes_permissions['user']) {
+ const userPermissions = role.scopes_permissions['user'];
+ return ['read', 'update'].every((permission) => {
+ return userPermissions.indexOf(permission) !== -1;
+ });
+ }
+ }
+ return false;
+ }
+
+ private doEditAction() {
+ const userFormModel = this.getRequest();
+ this.userService.update(userFormModel).subscribe(
+ () => {
+ if (this.isUserChangingRoles()) {
+ this.authService.logout(() => {
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`You were automatically logged out because your roles have been changed.`
+ );
+ });
+ } else {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated user '${userFormModel.username}'`
+ );
+ this.router.navigate(['/user-management/users']);
+ }
+ },
+ () => {
+ this.userForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ clearExpirationDate() {
+ this.userForm.get('pwdExpirationDate').setValue(undefined);
+ }
+
+ submit() {
+ if (this.mode === this.userFormMode.editing) {
+ this.editAction();
+ } else {
+ this.createAction();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts
new file mode 100644
index 000000000..2dc88ab5a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts
@@ -0,0 +1,10 @@
+export class UserFormModel {
+ username: string;
+ password: string;
+ pwdExpirationDate: number;
+ name: string;
+ email: string;
+ roles: Array<string>;
+ enabled: boolean;
+ pwdUpdateRequired: boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html
new file mode 100644
index 000000000..89ed21be8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html
@@ -0,0 +1,22 @@
+<cd-user-tabs></cd-user-tabs>
+
+<cd-table [data]="users"
+ columnMode="flex"
+ [columns]="columns"
+ identifier="username"
+ selectionType="single"
+ (fetchData)="getUsers()"
+ (updateSelection)="updateSelection($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+</cd-table>
+
+<ng-template #userRolesTpl
+ let-value="value">
+ <span *ngFor="let role of value; last as isLast">
+ {{ role }}{{ !isLast ? ", " : "" }}
+ </span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts
new file mode 100644
index 000000000..a1b9cfd14
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts
@@ -0,0 +1,82 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { UserTabsComponent } from '../user-tabs/user-tabs.component';
+import { UserListComponent } from './user-list.component';
+
+describe('UserListComponent', () => {
+ let component: UserListComponent;
+ let fixture: ComponentFixture<UserListComponent>;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ SharedModule,
+ ToastrModule.forRoot(),
+ NgbNavModule,
+ RouterTestingModule,
+ HttpClientTestingModule
+ ],
+ declarations: [UserListComponent, UserTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(UserListComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should test all TableActions combinations', () => {
+ const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Create', 'Edit', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,update': {
+ actions: ['Create', 'Edit'],
+ primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' }
+ },
+ 'create,delete': {
+ actions: ['Create', 'Delete'],
+ primary: { multiple: 'Create', executing: 'Delete', single: 'Delete', no: 'Create' }
+ },
+ create: {
+ actions: ['Create'],
+ primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Delete'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts
new file mode 100644
index 000000000..09c0d82fc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts
@@ -0,0 +1,164 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+
+import { UserService } from '~/app/shared/api/user.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { EmptyPipe } from '~/app/shared/pipes/empty.pipe';
+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 { URLBuilderService } from '~/app/shared/services/url-builder.service';
+
+const BASE_URL = 'user-management/users';
+
+@Component({
+ selector: 'cd-user-list',
+ templateUrl: './user-list.component.html',
+ styleUrls: ['./user-list.component.scss'],
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
+})
+export class UserListComponent implements OnInit {
+ @ViewChild('userRolesTpl', { static: true })
+ userRolesTpl: TemplateRef<any>;
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ columns: CdTableColumn[];
+ users: Array<any>;
+ selection = new CdTableSelection();
+
+ modalRef: NgbModalRef;
+
+ constructor(
+ private userService: UserService,
+ private emptyPipe: EmptyPipe,
+ private modalService: ModalService,
+ private notificationService: NotificationService,
+ private authStorageService: AuthStorageService,
+ private urlBuilder: URLBuilderService,
+ private cdDatePipe: CdDatePipe,
+ public actionLabels: ActionLabelsI18n
+ ) {
+ this.permission = this.authStorageService.getPermissions().user;
+ const addAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ routerLink: () => this.urlBuilder.getCreate(),
+ name: this.actionLabels.CREATE
+ };
+ const editAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ routerLink: () =>
+ this.selection.first() && this.urlBuilder.getEdit(this.selection.first().username),
+ name: this.actionLabels.EDIT
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteUserModal(),
+ name: this.actionLabels.DELETE
+ };
+ this.tableActions = [addAction, editAction, deleteAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: $localize`Username`,
+ prop: 'username',
+ flexGrow: 1
+ },
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 1,
+ pipe: this.emptyPipe
+ },
+ {
+ name: $localize`Email`,
+ prop: 'email',
+ flexGrow: 1,
+ pipe: this.emptyPipe
+ },
+ {
+ name: $localize`Roles`,
+ prop: 'roles',
+ flexGrow: 1,
+ cellTemplate: this.userRolesTpl
+ },
+ {
+ name: $localize`Enabled`,
+ prop: 'enabled',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.checkIcon
+ },
+ {
+ name: $localize`Password expiration date`,
+ prop: 'pwdExpirationDate',
+ flexGrow: 1,
+ pipe: this.cdDatePipe
+ }
+ ];
+ }
+
+ getUsers() {
+ this.userService.list().subscribe((users: Array<any>) => {
+ users.forEach((user) => {
+ if (user['pwdExpirationDate'] && user['pwdExpirationDate'] > 0) {
+ user['pwdExpirationDate'] = user['pwdExpirationDate'] * 1000;
+ }
+ });
+ this.users = users;
+ });
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ deleteUser(username: string) {
+ this.userService.delete(username).subscribe(
+ () => {
+ this.getUsers();
+ this.modalRef.close();
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Deleted user '${username}'`
+ );
+ },
+ () => {
+ this.modalRef.componentInstance.stopLoadingSpinner();
+ }
+ );
+ }
+
+ deleteUserModal() {
+ const sessionUsername = this.authStorageService.getUsername();
+ const username = this.selection.first().username;
+ if (sessionUsername === username) {
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Failed to delete user '${username}'`,
+ $localize`You are currently logged in as '${username}'.`
+ );
+ return;
+ }
+
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'User',
+ itemNames: [username],
+ submitAction: () => this.deleteUser(username)
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html
new file mode 100644
index 000000000..83eb40944
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html
@@ -0,0 +1,121 @@
+<div class="cd-col-form">
+ <form #frm="ngForm"
+ [formGroup]="userForm"
+ novalidate>
+ <div class="card">
+ <div i18n="form title"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+ <div class="card-body">
+ <!-- Old password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="oldpassword"
+ i18n>Old password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ placeholder="Old password..."
+ id="oldpassword"
+ formControlName="oldpassword"
+ autocomplete="new-password"
+ autofocus>
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ cdPasswordButton="oldpassword">
+ </button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('oldpassword', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('oldpassword', frm, 'notmatch')"
+ i18n>The old and new passwords must be different.</span>
+ </div>
+ </div>
+
+ <!-- New password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="newpassword">
+ <span class="required"
+ i18n>New password</span>
+ <cd-helper *ngIf="passwordPolicyHelpText.length > 0"
+ class="text-pre-wrap"
+ html="{{ passwordPolicyHelpText }}">
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ placeholder="Password..."
+ id="newpassword"
+ autocomplete="new-password"
+ formControlName="newpassword">
+ <span class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ cdPasswordButton="newpassword">
+ </button>
+ </span>
+ </div>
+ <div class="password-strength-level">
+ <div class="{{ passwordStrengthLevelClass }}"
+ data-toggle="tooltip"
+ title="{{ passwordValuation }}">
+ </div>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('newpassword', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('newpassword', frm, 'notmatch')"
+ i18n>The old and new passwords must be different.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('newpassword', frm, 'passwordPolicy')">
+ {{ passwordValuation }}
+ </span>
+ </div>
+ </div>
+
+ <!-- Confirm new password -->
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="confirmnewpassword"
+ i18n>Confirm new password</label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ type="password"
+ autocomplete="new-password"
+ placeholder="Confirm new password..."
+ id="confirmnewpassword"
+ formControlName="confirmnewpassword">
+ <span class="input-group-append">
+ <button class="btn btn-light"
+ cdPasswordButton="confirmnewpassword">
+ </button>
+ </span>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('confirmnewpassword', frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('confirmnewpassword', frm, 'match')"
+ i18n>Password confirmation doesn't match the new password.</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="card-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="userForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts
new file mode 100644
index 000000000..b1df8cf42
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts
@@ -0,0 +1,83 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
+import { UserPasswordFormComponent } from './user-password-form.component';
+
+describe('UserPasswordFormComponent', () => {
+ let component: UserPasswordFormComponent;
+ let fixture: ComponentFixture<UserPasswordFormComponent>;
+ let form: CdFormGroup;
+ let formHelper: FormHelper;
+ let httpTesting: HttpTestingController;
+ let router: Router;
+ let authStorageService: AuthStorageService;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ ReactiveFormsModule,
+ ComponentsModule,
+ ToastrModule.forRoot(),
+ SharedModule
+ ],
+ declarations: [UserPasswordFormComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(UserPasswordFormComponent);
+ component = fixture.componentInstance;
+ form = component.userForm;
+ httpTesting = TestBed.inject(HttpTestingController);
+ router = TestBed.inject(Router);
+ authStorageService = TestBed.inject(AuthStorageService);
+ spyOn(router, 'navigate');
+ fixture.detectChanges();
+ formHelper = new FormHelper(form);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should validate old password required', () => {
+ formHelper.expectErrorChange('oldpassword', '', 'required');
+ formHelper.expectValidChange('oldpassword', 'foo');
+ });
+
+ it('should validate password match', () => {
+ formHelper.setValue('newpassword', 'aaa');
+ formHelper.expectErrorChange('confirmnewpassword', 'bbb', 'match');
+ formHelper.expectValidChange('confirmnewpassword', 'aaa');
+ });
+
+ it('should submit', () => {
+ spyOn(component, 'onPasswordChange').and.callThrough();
+ spyOn(authStorageService, 'getUsername').and.returnValue('xyz');
+ formHelper.setMultipleValues({
+ oldpassword: 'foo',
+ newpassword: 'bar'
+ });
+ formHelper.setValue('confirmnewpassword', 'bar', true);
+ component.onSubmit();
+ const request = httpTesting.expectOne('api/user/xyz/change_password');
+ expect(request.request.method).toBe('POST');
+ expect(request.request.body).toEqual({
+ old_password: 'foo',
+ new_password: 'bar'
+ });
+ request.flush({});
+ expect(component.onPasswordChange).toHaveBeenCalled();
+ expect(router.navigate).toHaveBeenCalledWith(['/login']);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts
new file mode 100644
index 000000000..dffb927ac
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts
@@ -0,0 +1,119 @@
+import { Component } from '@angular/core';
+import { Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import _ from 'lodash';
+
+import { UserService } from '~/app/shared/api/user.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { PasswordPolicyService } from '~/app/shared/services/password-policy.service';
+
+@Component({
+ selector: 'cd-user-password-form',
+ templateUrl: './user-password-form.component.html',
+ styleUrls: ['./user-password-form.component.scss']
+})
+export class UserPasswordFormComponent {
+ userForm: CdFormGroup;
+ action: string;
+ resource: string;
+ passwordPolicyHelpText = '';
+ passwordStrengthLevelClass: string;
+ passwordValuation: string;
+ icons = Icons;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ public notificationService: NotificationService,
+ public userService: UserService,
+ public authStorageService: AuthStorageService,
+ public formBuilder: CdFormBuilder,
+ public router: Router,
+ public passwordPolicyService: PasswordPolicyService
+ ) {
+ this.action = this.actionLabels.CHANGE;
+ this.resource = $localize`password`;
+ this.createForm();
+ }
+
+ createForm() {
+ this.passwordPolicyService.getHelpText().subscribe((helpText: string) => {
+ this.passwordPolicyHelpText = helpText;
+ });
+ this.userForm = this.formBuilder.group(
+ {
+ oldpassword: [
+ null,
+ [
+ Validators.required,
+ CdValidators.custom('notmatch', () => {
+ return (
+ this.userForm &&
+ this.userForm.getValue('newpassword') === this.userForm.getValue('oldpassword')
+ );
+ })
+ ]
+ ],
+ newpassword: [
+ null,
+ [
+ Validators.required,
+ CdValidators.custom('notmatch', () => {
+ return (
+ this.userForm &&
+ this.userForm.getValue('oldpassword') === this.userForm.getValue('newpassword')
+ );
+ })
+ ],
+ [
+ CdValidators.passwordPolicy(
+ this.userService,
+ () => this.authStorageService.getUsername(),
+ (_valid: boolean, credits: number, valuation: string) => {
+ this.passwordStrengthLevelClass = this.passwordPolicyService.mapCreditsToCssClass(
+ credits
+ );
+ this.passwordValuation = _.defaultTo(valuation, '');
+ }
+ )
+ ]
+ ],
+ confirmnewpassword: [null, [Validators.required]]
+ },
+ {
+ validators: [CdValidators.match('newpassword', 'confirmnewpassword')]
+ }
+ );
+ }
+
+ onSubmit() {
+ if (this.userForm.pristine) {
+ return;
+ }
+ const username = this.authStorageService.getUsername();
+ const oldPassword = this.userForm.getValue('oldpassword');
+ const newPassword = this.userForm.getValue('newpassword');
+ this.userService.changePassword(username, oldPassword, newPassword).subscribe(
+ () => this.onPasswordChange(),
+ () => {
+ this.userForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+
+ /**
+ * The function that is called after the password has been changed.
+ * Override this in derived classes to change the behaviour.
+ */
+ onPasswordChange() {
+ this.notificationService.show(NotificationType.success, $localize`Updated user password"`);
+ this.router.navigate(['/login']);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html
new file mode 100644
index 000000000..3c102cf66
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html
@@ -0,0 +1,14 @@
+<ul ngbNav
+ #nav="ngbNav"
+ [activeId]="router.url"
+ (navChange)="router.navigate([$event.nextId])"
+ class="nav-tabs">
+ <li ngbNavItem="/user-management/users">
+ <a ngbNavLink
+ i18n>Users</a>
+ </li>
+ <li ngbNavItem="/user-management/roles">
+ <a ngbNavLink
+ i18n>Roles</a>
+ </li>
+</ul>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts
new file mode 100644
index 000000000..f9b8081db
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts
@@ -0,0 +1,29 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { UserTabsComponent } from './user-tabs.component';
+
+describe('UserTabsComponent', () => {
+ let component: UserTabsComponent;
+ let fixture: ComponentFixture<UserTabsComponent>;
+
+ configureTestBed({
+ imports: [SharedModule, RouterTestingModule, HttpClientTestingModule, NgbNavModule],
+ declarations: [UserTabsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(UserTabsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts
new file mode 100644
index 000000000..06626ec3e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'cd-user-tabs',
+ templateUrl: './user-tabs.component.html',
+ styleUrls: ['./user-tabs.component.scss']
+})
+export class UserTabsComponent {
+ url: string;
+
+ constructor(public router: Router) {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html
new file mode 100644
index 000000000..63af29e13
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html
@@ -0,0 +1,27 @@
+<ng-container *ngIf="{ ftMap: featureToggleMap$ | async, daemons: rgwDaemonService.daemons$ | async, selectedDaemon: rgwDaemonService.selectedDaemon$ | async } as data">
+ <ng-container *ngIf="data.ftMap && data.ftMap.rgw && permissions.rgw.read && isRgwRoute && data.daemons.length > 1">
+ <div class="cd-context-bar pt-3 pb-3">
+ <span class="mr-1"
+ i18n>Selected Object Gateway:</span>
+ <div ngbDropdown
+ placement="bottom-left"
+ class="d-inline-block ml-2">
+ <button ngbDropdownToggle
+ class="btn btn-outline-info ctx-bar-selected-rgw-daemon"
+ i18n-title
+ title="Select Object Gateway">
+ {{ data.selectedDaemon.id }} ( {{ data.selectedDaemon.zonegroup_name }} )
+ </button>
+ <div ngbDropdownMenu>
+ <ng-container *ngFor="let daemon of data.daemons">
+ <button ngbDropdownItem
+ class="ctx-bar-available-rgw-daemon"
+ (click)="onDaemonSelection(daemon)">
+ {{ daemon.id }} ( {{ daemon.zonegroup_name }} )
+ </button>
+ </ng-container>
+ </div>
+ </div>
+ </div>
+ </ng-container>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss
new file mode 100644
index 000000000..0cd44f150
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss
@@ -0,0 +1,5 @@
+@use './src/styles/vendor/variables' as vv;
+
+.cd-context-bar {
+ border-bottom: 1px solid vv.$gray-300;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts
new file mode 100644
index 000000000..9512e3183
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts
@@ -0,0 +1,100 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import {
+ FeatureTogglesMap,
+ FeatureTogglesService
+} from '~/app/shared/services/feature-toggles.service';
+import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper';
+import { ContextComponent } from './context.component';
+
+describe('ContextComponent', () => {
+ let component: ContextComponent;
+ let fixture: ComponentFixture<ContextComponent>;
+ let router: Router;
+ let routerNavigateByUrlSpy: jasmine.Spy;
+ let routerNavigateSpy: jasmine.Spy;
+ let getPermissionsSpy: jasmine.Spy;
+ let getFeatureTogglesSpy: jasmine.Spy;
+ let ftMap: FeatureTogglesMap;
+ let httpTesting: HttpTestingController;
+
+ const daemonList = RgwHelper.getDaemonList();
+
+ configureTestBed({
+ declarations: [ContextComponent],
+ imports: [HttpClientTestingModule, RouterTestingModule]
+ });
+
+ beforeEach(() => {
+ httpTesting = TestBed.inject(HttpTestingController);
+ router = TestBed.inject(Router);
+ routerNavigateByUrlSpy = spyOn(router, 'navigateByUrl');
+ routerNavigateByUrlSpy.and.returnValue(Promise.resolve(undefined));
+ routerNavigateSpy = spyOn(router, 'navigate');
+ getPermissionsSpy = spyOn(TestBed.inject(AuthStorageService), 'getPermissions');
+ getPermissionsSpy.and.returnValue(
+ new Permissions({ rgw: ['read', 'update', 'create', 'delete'] })
+ );
+ getFeatureTogglesSpy = spyOn(TestBed.inject(FeatureTogglesService), 'get');
+ ftMap = new FeatureTogglesMap();
+ ftMap.rgw = true;
+ getFeatureTogglesSpy.and.returnValue(of(ftMap));
+ fixture = TestBed.createComponent(ContextComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should not show any info if not in RGW route', () => {
+ component.isRgwRoute = false;
+ expect(fixture.debugElement.nativeElement.textContent).toEqual('');
+ });
+
+ it('should select the default daemon', fakeAsync(() => {
+ component.isRgwRoute = true;
+ fixture.detectChanges();
+ tick();
+ const req = httpTesting.expectOne('api/rgw/daemon');
+ req.flush(daemonList);
+ fixture.detectChanges();
+ const selectedDaemon = fixture.debugElement.nativeElement.querySelector(
+ '.ctx-bar-selected-rgw-daemon'
+ );
+ expect(selectedDaemon.textContent).toEqual(' daemon2 ( zonegroup2 ) ');
+
+ const availableDaemons = fixture.debugElement.nativeElement.querySelectorAll(
+ '.ctx-bar-available-rgw-daemon'
+ );
+ expect(availableDaemons.length).toEqual(daemonList.length);
+ expect(availableDaemons[0].textContent).toEqual(' daemon1 ( zonegroup1 ) ');
+ component.ngOnDestroy();
+ }));
+
+ it('should select the chosen daemon', fakeAsync(() => {
+ component.isRgwRoute = true;
+ fixture.detectChanges();
+ tick();
+ const req = httpTesting.expectOne('api/rgw/daemon');
+ req.flush(daemonList);
+ fixture.detectChanges();
+ component.onDaemonSelection(daemonList[2]);
+ expect(routerNavigateByUrlSpy).toHaveBeenCalledTimes(1);
+ fixture.detectChanges();
+ tick();
+ expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
+ const selectedDaemon = fixture.debugElement.nativeElement.querySelector(
+ '.ctx-bar-selected-rgw-daemon'
+ );
+ expect(selectedDaemon.textContent).toEqual(' daemon3 ( zonegroup3 ) ');
+ component.ngOnDestroy();
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts
new file mode 100644
index 000000000..8de611307
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts
@@ -0,0 +1,70 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Event, NavigationEnd, Router } from '@angular/router';
+
+import { NEVER, Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
+
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import {
+ FeatureTogglesMap$,
+ FeatureTogglesService
+} from '~/app/shared/services/feature-toggles.service';
+import { TimerService } from '~/app/shared/services/timer.service';
+
+@Component({
+ selector: 'cd-context',
+ templateUrl: './context.component.html',
+ styleUrls: ['./context.component.scss']
+})
+export class ContextComponent implements OnInit, OnDestroy {
+ readonly REFRESH_INTERVAL = 5000;
+ private subs = new Subscription();
+ private rgwUrlPrefix = '/rgw';
+ permissions: Permissions;
+ featureToggleMap$: FeatureTogglesMap$;
+ isRgwRoute = document.location.href.includes(this.rgwUrlPrefix);
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private featureToggles: FeatureTogglesService,
+ private router: Router,
+ private timerService: TimerService,
+ public rgwDaemonService: RgwDaemonService
+ ) {}
+
+ ngOnInit() {
+ this.permissions = this.authStorageService.getPermissions();
+ this.featureToggleMap$ = this.featureToggles.get();
+ // Check if route belongs to RGW:
+ this.subs.add(
+ this.router.events
+ .pipe(filter((event: Event) => event instanceof NavigationEnd))
+ .subscribe(() => (this.isRgwRoute = this.router.url.startsWith(this.rgwUrlPrefix)))
+ );
+ // Set daemon list polling only when in RGW route:
+ this.subs.add(
+ this.timerService
+ .get(() => (this.isRgwRoute ? this.rgwDaemonService.list() : NEVER), this.REFRESH_INTERVAL)
+ .subscribe()
+ );
+ }
+
+ ngOnDestroy() {
+ this.subs.unsubscribe();
+ }
+
+ onDaemonSelection(daemon: RgwDaemon) {
+ this.rgwDaemonService.selectDaemon(daemon);
+ this.reloadData();
+ }
+
+ private reloadData() {
+ const currentUrl = this.router.url;
+ this.router.navigateByUrl(this.rgwUrlPrefix, { skipLocationChange: true }).finally(() => {
+ this.router.navigate([currentUrl]);
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts
new file mode 100644
index 000000000..005c82778
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts
@@ -0,0 +1,34 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
+import { BlockUIModule } from 'ng-block-ui';
+
+import { ContextComponent } from '~/app/core/context/context.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ErrorComponent } from './error/error.component';
+import { BlankLayoutComponent } from './layouts/blank-layout/blank-layout.component';
+import { LoginLayoutComponent } from './layouts/login-layout/login-layout.component';
+import { WorkbenchLayoutComponent } from './layouts/workbench-layout/workbench-layout.component';
+import { NavigationModule } from './navigation/navigation.module';
+
+@NgModule({
+ imports: [
+ BlockUIModule.forRoot(),
+ CommonModule,
+ NavigationModule,
+ NgbDropdownModule,
+ RouterModule,
+ SharedModule
+ ],
+ exports: [NavigationModule],
+ declarations: [
+ ContextComponent,
+ WorkbenchLayoutComponent,
+ BlankLayoutComponent,
+ LoginLayoutComponent,
+ ErrorComponent
+ ]
+})
+export class CoreModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html
new file mode 100644
index 000000000..164c181da
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html
@@ -0,0 +1,63 @@
+<head>
+ <title>Error Page</title>
+ <base target="_blank">
+</head>
+<div class="container h-75">
+ <div class="row h-100 justify-content-center align-items-center">
+ <div class="blank-page">
+ <div *ngIf="header && message; else elseBlock">
+ <i [ngClass]="icon"
+ class="mx-auto d-block"></i>
+
+ <div class="mt-4 text-center">
+ <h3><b>{{ header }}</b></h3>
+ <h4 class="mt-3"
+ *ngIf="header !== message">{{ message }}</h4>
+ <h4 *ngIf="section"
+ i18n>Please consult the <a href="{{ docUrl }}">documentation</a> on how to configure and enable
+ the {{ sectionInfo }} management functionality.
+ </h4>
+ </div>
+ </div>
+
+ <div class="mt-4">
+ <div class="text-center"
+ *ngIf="(buttonName && buttonRoute) || uiConfig; else dashboardButton">
+ <button class="btn btn-primary"
+ [routerLink]="buttonRoute"
+ *ngIf="!uiConfig; else configureButtonTpl"
+ i18n>{{ buttonName }}</button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+<ng-template #configureButtonTpl>
+ <button class="btn btn-primary"
+ (click)="doConfigure()"
+ [attr.title]="buttonTitle"
+ *ngIf="uiConfig"
+ i18n>{{ buttonName }}</button>
+</ng-template>
+
+
+<ng-template #elseBlock>
+ <i class="fa fa-exclamation-triangle mx-auto d-block text-danger"></i>
+
+ <div class="mt-4 text-center">
+ <h3 i18n><b>Page not Found</b></h3>
+
+ <h4 class="mt-4"
+ i18n>Sorry, we couldn’t find what you were looking for.
+ The page you requested may have been changed or moved.</h4>
+ </div>
+</ng-template>
+
+<ng-template #dashboardButton>
+ <div class="mt-4 text-center">
+ <button class="btn btn-primary"
+ [routerLink]="'/dashboard'"
+ i18n>Go To Dashboard</button>
+ </div>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss
new file mode 100644
index 000000000..feb4e0f95
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss
@@ -0,0 +1,18 @@
+@use './src/styles/vendor/variables' as vv;
+
+h4 {
+ color: vv.$gray-700;
+}
+
+i {
+ font-size: 6em;
+ margin-top: 120px;
+}
+
+.fa-lock {
+ color: vv.$danger;
+}
+
+.fa-wrench {
+ color: vv.$info;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts
new file mode 100644
index 000000000..5763d4d97
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts
@@ -0,0 +1,49 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ErrorComponent } from './error.component';
+
+describe('ErrorComponent', () => {
+ let component: ErrorComponent;
+ let fixture: ComponentFixture<ErrorComponent>;
+
+ configureTestBed({
+ declarations: [ErrorComponent],
+ imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ToastrModule.forRoot()]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ErrorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should show error message and header', () => {
+ window.history.pushState({ message: 'Access Forbidden', header: 'User Denied' }, 'Errors');
+ component.fetchData();
+ fixture.detectChanges();
+ const header = fixture.debugElement.nativeElement.querySelector('h3');
+ expect(header.innerHTML).toContain('User Denied');
+ const message = fixture.debugElement.nativeElement.querySelector('h4');
+ expect(message.innerHTML).toContain('Access Forbidden');
+ });
+
+ it('should show 404 Page not Found if message and header are blank', () => {
+ window.history.pushState({ message: '', header: '' }, 'Errors');
+ component.fetchData();
+ fixture.detectChanges();
+ const header = fixture.debugElement.nativeElement.querySelector('h3');
+ expect(header.innerHTML).toContain('Page not Found');
+ const message = fixture.debugElement.nativeElement.querySelector('h4');
+ expect(message.innerHTML).toContain('Sorry, we couldn’t find what you were looking for.');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts
new file mode 100644
index 000000000..d26bc6db4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts
@@ -0,0 +1,96 @@
+import { HttpClient } from '@angular/common/http';
+import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
+import { NavigationEnd, Router, RouterEvent } from '@angular/router';
+
+import { Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
+
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { DocService } from '~/app/shared/services/doc.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+ selector: 'cd-error',
+ templateUrl: './error.component.html',
+ styleUrls: ['./error.component.scss']
+})
+export class ErrorComponent implements OnDestroy, OnInit {
+ header: string;
+ message: string;
+ section: string;
+ sectionInfo: string;
+ icon: string;
+ docUrl: string;
+ source: string;
+ routerSubscription: Subscription;
+ uiConfig: string;
+ uiApiPath: string;
+ buttonRoute: string;
+ buttonName: string;
+ buttonTitle: string;
+ component: string;
+
+ constructor(
+ private router: Router,
+ private docService: DocService,
+ private http: HttpClient,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnInit() {
+ this.fetchData();
+ this.routerSubscription = this.router.events
+ .pipe(filter((event: RouterEvent) => event instanceof NavigationEnd))
+ .subscribe(() => {
+ this.fetchData();
+ });
+ }
+
+ doConfigure() {
+ this.http.post(`ui-api/${this.uiApiPath}/configure`, {}).subscribe({
+ next: () => {
+ this.notificationService.show(NotificationType.info, `Configuring ${this.component}`);
+ },
+ error: (error: any) => {
+ this.notificationService.show(NotificationType.error, error);
+ },
+ complete: () => {
+ setTimeout(() => {
+ this.router.navigate([this.uiApiPath]);
+ this.notificationService.show(NotificationType.success, `Configured ${this.component}`);
+ }, 3000);
+ }
+ });
+ }
+
+ @HostListener('window:beforeunload', ['$event']) unloadHandler(event: Event) {
+ event.returnValue = false;
+ }
+
+ fetchData() {
+ try {
+ this.router.onSameUrlNavigation = 'reload';
+ this.message = history.state.message;
+ this.header = history.state.header;
+ this.section = history.state.section;
+ this.sectionInfo = history.state.section_info;
+ this.icon = history.state.icon;
+ this.source = history.state.source;
+ this.uiConfig = history.state.uiConfig;
+ this.uiApiPath = history.state.uiApiPath;
+ this.buttonRoute = history.state.button_route;
+ this.buttonName = history.state.button_name;
+ this.buttonTitle = history.state.button_title;
+ this.component = history.state.component;
+ this.docUrl = this.docService.urlGenerator(this.section);
+ } catch (error) {
+ this.router.navigate(['/error']);
+ }
+ }
+
+ ngOnDestroy() {
+ if (this.routerSubscription) {
+ this.routerSubscription.unsubscribe();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts
new file mode 100644
index 000000000..0270a4587
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts
@@ -0,0 +1,27 @@
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+export class DashboardError extends Error {
+ header: string;
+ message: string;
+ icon: string;
+}
+
+export class DashboardNotFoundError extends DashboardError {
+ header = $localize`Page Not Found`;
+ message = $localize`Sorry, we couldn’t find what you were looking for.
+ The page you requested may have been changed or moved.`;
+ icon = Icons.warning;
+}
+
+export class DashboardForbiddenError extends DashboardError {
+ header = $localize`Access Denied`;
+ message = $localize`Sorry, you don’t have permission to view this page or resource.`;
+ icon = Icons.lock;
+}
+
+export class DashboardUserDeniedError extends DashboardError {
+ header = $localize`User Denied`;
+ message = $localize`Sorry, the user does not exist in Ceph.
+ You'll be logged out from the Identity Provider when you retry logging in.`;
+ icon = Icons.warning;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html
new file mode 100644
index 000000000..0680b43f9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html
@@ -0,0 +1 @@
+<router-outlet></router-outlet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts
new file mode 100644
index 000000000..faee6aa9b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { BlankLayoutComponent } from './blank-layout.component';
+
+describe('DefaultLayoutComponent', () => {
+ let component: BlankLayoutComponent;
+ let fixture: ComponentFixture<BlankLayoutComponent>;
+
+ configureTestBed({
+ declarations: [BlankLayoutComponent],
+ imports: [RouterTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BlankLayoutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts
new file mode 100644
index 000000000..761bb3b87
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts
@@ -0,0 +1,8 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'cd-blank-layout',
+ templateUrl: './blank-layout.component.html',
+ styleUrls: ['./blank-layout.component.scss']
+})
+export class BlankLayoutComponent {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html
new file mode 100644
index 000000000..1222fcc2a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html
@@ -0,0 +1,34 @@
+<main class="login full-height">
+ <header>
+ <nav class="navbar p-4">
+ <a class="navbar-brand"></a>
+ <div class="form-inline">
+ <cd-language-selector></cd-language-selector>
+ </div>
+ </nav>
+ </header>
+ <section>
+ <div class="container">
+ <div class="row full-height">
+ <div class="col-sm-12 col-md-6 d-sm-block login-form">
+ <router-outlet></router-outlet>
+ </div>
+ <div class="col-sm-12 col-md-6 d-sm-block branding-info">
+ <img src="assets/Ceph_Ceph_Logo_with_text_white.svg"
+ alt="Ceph"
+ class="img-fluid pb-3">
+ <ul class="list-inline">
+ <li class="list-inline-item p-3"
+ *ngFor="let docItem of docItems">
+ <cd-doc section="{{ docItem.section }}"
+ docText="{{ docItem.text }}"
+ noSubscribe="true"
+ i18n-docText></cd-doc>
+ </li>
+ </ul>
+ <cd-custom-login-banner></cd-custom-login-banner>
+ </div>
+ </div>
+ </div>
+ </section>
+</main>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss
new file mode 100644
index 000000000..d5c9f73ec
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss
@@ -0,0 +1,61 @@
+@use './src/styles/vendor/variables' as vv;
+
+::ng-deep cd-login-layout .login {
+ background-color: vv.$secondary;
+ background-image: url('../../../../assets/ceph_background.gif');
+ background-position: right bottom;
+ background-repeat: no-repeat;
+ color: vv.$body-color-bright;
+
+ header {
+ position: absolute;
+ width: 100vw;
+
+ .navbar {
+ .dropdown-menu {
+ margin-top: 0.2rem;
+
+ li a {
+ &:hover {
+ background-color: vv.$primary;
+ }
+ }
+ }
+ }
+ }
+
+ section {
+ display: inline-flex;
+ min-height: 100vh;
+ width: 100vw;
+ }
+
+ .list-inline {
+ margin-bottom: 0;
+ margin-left: 20%;
+ }
+
+ a {
+ color: vv.$fg-color-over-dark-bg;
+
+ &:hover {
+ color: vv.$fg-hover-color-over-dark-bg;
+ }
+ }
+
+ @media screen and (min-width: vv.$screen-sm-min) {
+ .login-form,
+ .branding-info {
+ padding-top: 30vh;
+ }
+ }
+ @media screen and (max-width: vv.$screen-sm-max) {
+ .login-form {
+ padding-top: 10vh;
+ }
+
+ .branding-info {
+ padding-top: 0;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts
new file mode 100644
index 000000000..b57e9a36e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts
@@ -0,0 +1,28 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LoginLayoutComponent } from './login-layout.component';
+
+describe('LoginLayoutComponent', () => {
+ let component: LoginLayoutComponent;
+ let fixture: ComponentFixture<LoginLayoutComponent>;
+
+ configureTestBed({
+ declarations: [LoginLayoutComponent],
+ imports: [BrowserAnimationsModule, HttpClientTestingModule, RouterTestingModule, SharedModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LoginLayoutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts
new file mode 100644
index 000000000..69d591cd1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts
@@ -0,0 +1,14 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'cd-login-layout',
+ templateUrl: './login-layout.component.html',
+ styleUrls: ['./login-layout.component.scss']
+})
+export class LoginLayoutComponent {
+ docItems: any[] = [
+ { section: 'help', text: $localize`Help` },
+ { section: 'security', text: $localize`Security` },
+ { section: 'trademarks', text: $localize`Trademarks` }
+ ];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html
new file mode 100644
index 000000000..3979ad7a4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html
@@ -0,0 +1,10 @@
+<block-ui>
+ <cd-navigation>
+ <div class="container-fluid h-100"
+ [ngClass]="{'dashboard':isDashboardPage()} ">
+ <cd-context></cd-context>
+ <cd-breadcrumbs></cd-breadcrumbs>
+ <router-outlet></router-outlet>
+ </div>
+ </cd-navigation>
+</block-ui>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss
new file mode 100644
index 000000000..7ec90d43e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss
@@ -0,0 +1,12 @@
+@use './src/styles/vendor/variables' as vv;
+
+.dashboard {
+ background-color: vv.$body-bg-alt;
+ margin: 0;
+ padding: 0;
+}
+
+.container-fluid {
+ overflow: auto;
+ position: absolute;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts
new file mode 100644
index 000000000..faf8c9cdf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts
@@ -0,0 +1,35 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { WorkbenchLayoutComponent } from './workbench-layout.component';
+
+describe('WorkbenchLayoutComponent', () => {
+ let component: WorkbenchLayoutComponent;
+ let fixture: ComponentFixture<WorkbenchLayoutComponent>;
+
+ configureTestBed({
+ imports: [RouterTestingModule, ToastrModule.forRoot(), PipesModule, HttpClientTestingModule],
+ declarations: [WorkbenchLayoutComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [AuthStorageService, CssHelper, RbdService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(WorkbenchLayoutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts
new file mode 100644
index 000000000..f2070be5f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts
@@ -0,0 +1,39 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { Subscription } from 'rxjs';
+
+import { FaviconService } from '~/app/shared/services/favicon.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskManagerService } from '~/app/shared/services/task-manager.service';
+
+@Component({
+ selector: 'cd-workbench-layout',
+ templateUrl: './workbench-layout.component.html',
+ styleUrls: ['./workbench-layout.component.scss'],
+ providers: [FaviconService]
+})
+export class WorkbenchLayoutComponent implements OnInit, OnDestroy {
+ private subs = new Subscription();
+
+ constructor(
+ private router: Router,
+ private summaryService: SummaryService,
+ private taskManagerService: TaskManagerService,
+ private faviconService: FaviconService
+ ) {}
+
+ ngOnInit() {
+ this.subs.add(this.summaryService.startPolling());
+ this.subs.add(this.taskManagerService.init(this.summaryService));
+ this.faviconService.init();
+ }
+
+ ngOnDestroy() {
+ this.subs.unsubscribe();
+ }
+
+ isDashboardPage() {
+ return this.router.url === '/dashboard';
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html
new file mode 100644
index 000000000..fdf4d95cf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html
@@ -0,0 +1,47 @@
+<div class="about-container">
+ <div class="modal-header">
+ <button type="button"
+ class="close float-right"
+ aria-label="Close"
+ (click)="activeModal.close()">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <img src="assets/Ceph_Ceph_Logo_with_text_red_white.svg"
+ class="ceph-logo"
+ alt="{{ projectConstants.organization }}">
+ <h3>
+ <strong>{{ projectConstants.projectName }}</strong>
+ </h3>
+ <div class="product-versions">
+ <strong>Version</strong>
+ <br>
+ {{ versionNumber }}
+ {{ versionHash }}
+ <br>
+ {{ versionName }}
+ </div>
+ <br>
+ <dl>
+ <dt>Ceph Manager</dt>
+ <dd>{{ hostAddr }}</dd>
+ <dt>User</dt>
+ <dd>{{ modalVariables.user }}</dd>
+ <dt>User Role</dt>
+ <dd>{{ modalVariables.role }}</dd>
+ <dt>Browser</dt>
+ <dd>{{ modalVariables.browserName }}</dd>
+ <dt>Browser Version</dt>
+ <dd>{{ modalVariables.browserVersion }}</dd>
+ <dt>Browser OS</dt>
+ <dd>{{ modalVariables.browserOS }}</dd>
+ </dl>
+ </div>
+ <div class="modal-footer">
+ <div class="text-left">
+ {{ projectConstants.copyright }}
+ {{ projectConstants.license }}
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss
new file mode 100644
index 000000000..78c7fe550
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss
@@ -0,0 +1,43 @@
+@use './src/styles/vendor/variables' as vv;
+
+.about-container {
+ background-color: vv.$secondary;
+ background-image: url('../../../../assets/ceph_background.gif');
+ background-position: right bottom;
+ background-repeat: no-repeat;
+ color: vv.$white;
+ text-shadow: 1px 1px vv.$secondary;
+}
+
+.product-versions {
+ margin-top: 30px;
+}
+
+.product-versions strong {
+ margin-right: 10px;
+}
+
+.modal-header {
+ border-bottom: 0;
+}
+
+.modal-header .close {
+ color: vv.$white;
+ font-size: 2em;
+}
+
+.modal-body {
+ padding-left: 80px;
+ padding-right: 80px;
+}
+
+.ceph-logo {
+ margin-bottom: 30px;
+ width: 25%;
+}
+
+.modal-footer {
+ border-top: 0;
+ display: block;
+ padding: 15px 80px 35px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts
new file mode 100644
index 000000000..74ca78434
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts
@@ -0,0 +1,60 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { BehaviorSubject } from 'rxjs';
+
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { environment } from '~/environments/environment';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AboutComponent } from './about.component';
+
+export class SummaryServiceMock {
+ summaryDataSource = new BehaviorSubject({
+ version:
+ 'ceph version 14.0.0-855-gb8193bb4cd ' +
+ '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) nautilus (dev)',
+ mgr_host: 'http://localhost:11000/'
+ });
+ summaryData$ = this.summaryDataSource.asObservable();
+
+ subscribe(call: any) {
+ return this.summaryData$.subscribe(call);
+ }
+}
+
+describe('AboutComponent', () => {
+ let component: AboutComponent;
+ let fixture: ComponentFixture<AboutComponent>;
+
+ configureTestBed({
+ imports: [SharedModule, HttpClientTestingModule],
+ declarations: [AboutComponent],
+ providers: [NgbActiveModal, { provide: SummaryService, useClass: SummaryServiceMock }]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AboutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should parse version', () => {
+ expect(component.versionNumber).toBe('14.0.0-855-gb8193bb4cd');
+ expect(component.versionHash).toBe('(b8193bb4cda16ccc5b028c3e1df62bc72350a15d)');
+ expect(component.versionName).toBe('nautilus (dev)');
+ });
+
+ it('should get host', () => {
+ expect(component.hostAddr).toBe('localhost:11000');
+ });
+
+ it('should display copyright', () => {
+ expect(component.projectConstants.copyright).toContain(environment.year);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts
new file mode 100644
index 000000000..64b26bfc6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts
@@ -0,0 +1,70 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { detect } from 'detect-browser';
+import { Subscription } from 'rxjs';
+
+import { UserService } from '~/app/shared/api/user.service';
+import { AppConstants } from '~/app/shared/constants/app.constants';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+
+@Component({
+ selector: 'cd-about',
+ templateUrl: './about.component.html',
+ styleUrls: ['./about.component.scss']
+})
+export class AboutComponent implements OnInit, OnDestroy {
+ modalVariables: any;
+ versionNumber: string;
+ versionHash: string;
+ versionName: string;
+ subs: Subscription;
+ userPermission: Permission;
+ projectConstants: typeof AppConstants;
+ hostAddr: string;
+ copyright: string;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private summaryService: SummaryService,
+ private userService: UserService,
+ private authStorageService: AuthStorageService
+ ) {
+ this.userPermission = this.authStorageService.getPermissions().user;
+ }
+
+ ngOnInit() {
+ this.projectConstants = AppConstants;
+ this.hostAddr = window.location.hostname;
+ this.modalVariables = this.setVariables();
+ this.subs = this.summaryService.subscribe((summary) => {
+ const version = summary.version.replace('ceph version ', '').split(' ');
+ this.hostAddr = summary.mgr_host.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, '');
+ this.versionNumber = version[0];
+ this.versionHash = version[1];
+ this.versionName = version.slice(2, version.length).join(' ');
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ setVariables() {
+ const project = {} as any;
+ project.user = localStorage.getItem('dashboard_username');
+ project.role = 'user';
+ if (this.userPermission.read) {
+ this.userService.get(project.user).subscribe((data: any) => {
+ project.role = data.roles;
+ });
+ }
+ const browser = detect();
+ project.browserName = browser && browser.name ? browser.name : 'Not detected';
+ project.browserVersion = browser && browser.version ? browser.version : 'Not detected';
+ project.browserOS = browser && browser.os ? browser.os : 'Not detected';
+ return project;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html
new file mode 100644
index 000000000..fa4b38d16
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html
@@ -0,0 +1,22 @@
+<div ngbDropdown
+ placement="bottom-right"
+ *ngIf="userPermission.read">
+ <a ngbDropdownToggle
+ class="dropdown-toggle"
+ i18n-title
+ title="Dashboard Settings">
+ <i [ngClass]="[icons.deepCheck]"></i>
+ <span i18n
+ class="d-md-none">Dashboard Settings</span>
+ </a>
+ <div ngbDropdownMenu>
+ <button ngbDropdownItem
+ *ngIf="userPermission.read"
+ routerLink="/user-management"
+ i18n>User management</button>
+ <button ngbDropdownItem
+ *ngIf="configOptPermission.read"
+ routerLink="/telemetry"
+ i18n>Telemetry configuration</button>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts
new file mode 100644
index 000000000..29392785b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.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 { AdministrationComponent } from './administration.component';
+
+describe('AdministrationComponent', () => {
+ let component: AdministrationComponent;
+ let fixture: ComponentFixture<AdministrationComponent>;
+
+ configureTestBed({
+ imports: [SharedModule],
+ declarations: [AdministrationComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AdministrationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts
new file mode 100644
index 000000000..60cd17ec6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts
@@ -0,0 +1,22 @@
+import { Component } from '@angular/core';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-administration',
+ templateUrl: './administration.component.html',
+ styleUrls: ['./administration.component.scss']
+})
+export class AdministrationComponent {
+ userPermission: Permission;
+ configOptPermission: Permission;
+ icons = Icons;
+
+ constructor(private authStorageService: AuthStorageService) {
+ const permissions = this.authStorageService.getPermissions();
+ this.userPermission = permissions.user;
+ this.configOptPermission = permissions.configOpt;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html
new file mode 100644
index 000000000..2dd0ff424
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html
@@ -0,0 +1,3 @@
+
+<div id="swagger-ui"
+ class="apiDocs"></div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss
new file mode 100644
index 000000000..889286488
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss
@@ -0,0 +1,7 @@
+@use './src/styles/vendor/variables' as vv;
+
+.apiDocs {
+ background: vv.$gray-100;
+ font-size: 18px !important;
+ margin-top: -48px !important;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts
new file mode 100644
index 000000000..7d9ea86eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts
@@ -0,0 +1,18 @@
+import { Component, OnInit } from '@angular/core';
+
+import SwaggerUI from 'swagger-ui';
+
+@Component({
+ selector: 'cd-api-docs',
+ templateUrl: './api-docs.component.html',
+ styleUrls: ['./api-docs.component.scss']
+})
+export class ApiDocsComponent implements OnInit {
+ ngOnInit(): void {
+ SwaggerUI({
+ url: window.location.origin + '/docs/openapi.json',
+ dom_id: '#swagger-ui',
+ layout: 'BaseLayout'
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html
new file mode 100644
index 000000000..05232b7fa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html
@@ -0,0 +1,11 @@
+<ol *ngIf="crumbs.length"
+ class="breadcrumb">
+ <li *ngFor="let crumb of crumbs; let last = last"
+ [ngClass]="{ 'active': last && finished }"
+ class="breadcrumb-item">
+ <a *ngIf="!last && crumb.path !== null"
+ [routerLink]="crumb.path"
+ preserveFragment>{{ crumb.text }}</a>
+ <span *ngIf="last || crumb.path === null">{{ crumb.text }}</span>
+ </li>
+</ol>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss
new file mode 100644
index 000000000..733f7e677
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss
@@ -0,0 +1,12 @@
+.breadcrumb {
+ background-color: transparent;
+ border-radius: 0;
+ margin-top: 8px;
+ padding: 8px 0;
+}
+
+.breadcrumb > li + li::before {
+ content: '\f101';
+ font-family: 'ForkAwesome';
+ padding: 0 5px 0 7px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts
new file mode 100644
index 000000000..b6551f780
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts
@@ -0,0 +1,131 @@
+import { CommonModule } from '@angular/common';
+import { Component } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { PerformanceCounterBreadcrumbsResolver } from '~/app/app-routing.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { BreadcrumbsComponent } from './breadcrumbs.component';
+
+describe('BreadcrumbsComponent', () => {
+ let component: BreadcrumbsComponent;
+ let fixture: ComponentFixture<BreadcrumbsComponent>;
+ let router: Router;
+
+ @Component({ selector: 'cd-fake', template: '' })
+ class FakeComponent {}
+
+ const routes: Routes = [
+ {
+ path: 'hosts',
+ component: FakeComponent,
+ data: { breadcrumbs: 'Cluster/Hosts' }
+ },
+ {
+ path: 'perf_counters',
+ component: FakeComponent,
+ data: {
+ breadcrumbs: PerformanceCounterBreadcrumbsResolver
+ }
+ },
+ {
+ path: 'block',
+ data: { breadcrumbs: true, text: 'Block', path: null },
+ children: [
+ {
+ path: 'rbd',
+ data: { breadcrumbs: 'Images' },
+ children: [
+ { path: '', component: FakeComponent },
+ { path: 'add', component: FakeComponent, data: { breadcrumbs: 'Add' } }
+ ]
+ }
+ ]
+ }
+ ];
+
+ configureTestBed({
+ declarations: [BreadcrumbsComponent, FakeComponent],
+ imports: [CommonModule, RouterTestingModule.withRoutes(routes)],
+ providers: [PerformanceCounterBreadcrumbsResolver]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BreadcrumbsComponent);
+ router = TestBed.inject(Router);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ expect(component.crumbs).toEqual([]);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.subscription).toBeDefined();
+ });
+
+ it('should run postProcess and split the breadcrumbs when navigating to hosts', fakeAsync(() => {
+ fixture.ngZone.run(() => {
+ router.navigateByUrl('/hosts');
+ });
+ tick();
+ expect(component.crumbs).toEqual([
+ { path: null, text: 'Cluster' },
+ { path: '/hosts', text: 'Hosts' }
+ ]);
+ }));
+
+ it('should display empty breadcrumb when navigating to perf_counters from unknown path', fakeAsync(() => {
+ fixture.ngZone.run(() => {
+ router.navigateByUrl('/perf_counters');
+ });
+ tick();
+ expect(component.crumbs).toEqual([
+ { path: null, text: 'Cluster' },
+ { path: null, text: '' },
+ { path: '', text: 'Performance Counters' }
+ ]);
+ }));
+
+ it('should display Monitor breadcrumb when navigating to perf_counters from Monitors', fakeAsync(() => {
+ fixture.ngZone.run(() => {
+ router.navigate(['/perf_counters'], { queryParams: { fromLink: '/monitor' } });
+ });
+ tick();
+ expect(component.crumbs).toEqual([
+ { path: null, text: 'Cluster' },
+ { path: '/monitor', text: 'Monitors' },
+ { path: '', text: 'Performance Counters' }
+ ]);
+ }));
+
+ it('should display Hosts breadcrumb when navigating to perf_counters from Hosts', fakeAsync(() => {
+ fixture.ngZone.run(() => {
+ router.navigate(['/perf_counters'], { queryParams: { fromLink: '/hosts' } });
+ });
+ tick();
+ expect(component.crumbs).toEqual([
+ { path: null, text: 'Cluster' },
+ { path: '/hosts', text: 'Hosts' },
+ { path: '', text: 'Performance Counters' }
+ ]);
+ }));
+
+ it('should show all 3 breadcrumbs when navigating to RBD Add', fakeAsync(() => {
+ fixture.ngZone.run(() => {
+ router.navigateByUrl('/block/rbd/add');
+ });
+ tick();
+ expect(component.crumbs).toEqual([
+ { path: null, text: 'Block' },
+ { path: '/block/rbd', text: 'Images' },
+ { path: '/block/rbd/add', text: 'Add' }
+ ]);
+ }));
+
+ it('should unsubscribe on ngOnDestroy', () => {
+ expect(component.subscription.closed).toBeFalsy();
+ component.ngOnDestroy();
+ expect(component.subscription.closed).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts
new file mode 100644
index 000000000..d933081ab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts
@@ -0,0 +1,141 @@
+/*
+The MIT License
+
+Copyright (c) 2017 (null) McNull https://github.com/McNull
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+ */
+
+import { Component, Injector, OnDestroy } from '@angular/core';
+import { ActivatedRouteSnapshot, NavigationEnd, NavigationStart, Router } from '@angular/router';
+
+import { concat, from, Observable, of, Subscription } from 'rxjs';
+import { distinct, filter, first, mergeMap, toArray } from 'rxjs/operators';
+
+import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
+
+@Component({
+ selector: 'cd-breadcrumbs',
+ templateUrl: './breadcrumbs.component.html',
+ styleUrls: ['./breadcrumbs.component.scss']
+})
+export class BreadcrumbsComponent implements OnDestroy {
+ crumbs: IBreadcrumb[] = [];
+ /**
+ * Useful for e2e tests.
+ * This allow us to mark the breadcrumb as pending during the navigation from
+ * one page to another.
+ * This resolves the problem of validating the breadcrumb of a new page and
+ * still get the value from the previous
+ */
+ finished = false;
+ subscription: Subscription;
+ private defaultResolver = new BreadcrumbsResolver();
+
+ constructor(private router: Router, private injector: Injector) {
+ this.subscription = this.router.events
+ .pipe(filter((x) => x instanceof NavigationStart))
+ .subscribe(() => {
+ this.finished = false;
+ });
+
+ this.subscription = this.router.events
+ .pipe(filter((x) => x instanceof NavigationEnd))
+ .subscribe(() => {
+ const currentRoot = router.routerState.snapshot.root;
+
+ this._resolveCrumbs(currentRoot)
+ .pipe(
+ mergeMap((x) => x),
+ distinct((x) => x.text),
+ toArray(),
+ mergeMap((x) => {
+ const y = this.postProcess(x);
+ return this.wrapIntoObservable<IBreadcrumb[]>(y).pipe(first());
+ })
+ )
+ .subscribe((x) => {
+ this.finished = true;
+ this.crumbs = x;
+ });
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subscription.unsubscribe();
+ }
+
+ private _resolveCrumbs(route: ActivatedRouteSnapshot): Observable<IBreadcrumb[]> {
+ let crumbs$: Observable<IBreadcrumb[]>;
+
+ const data = route.routeConfig && route.routeConfig.data;
+
+ if (data && data.breadcrumbs) {
+ let resolver: BreadcrumbsResolver;
+
+ if (data.breadcrumbs.prototype instanceof BreadcrumbsResolver) {
+ resolver = this.injector.get<BreadcrumbsResolver>(data.breadcrumbs);
+ } else {
+ resolver = this.defaultResolver;
+ }
+
+ const result = resolver.resolve(route);
+ crumbs$ = this.wrapIntoObservable<IBreadcrumb[]>(result).pipe(first());
+ } else {
+ crumbs$ = of([]);
+ }
+
+ if (route.firstChild) {
+ crumbs$ = concat<IBreadcrumb[]>(crumbs$, this._resolveCrumbs(route.firstChild));
+ }
+
+ return crumbs$;
+ }
+
+ postProcess(breadcrumbs: IBreadcrumb[]) {
+ const result: IBreadcrumb[] = [];
+ breadcrumbs.forEach((element) => {
+ const split = element.text.split('/');
+ if (split.length > 1) {
+ element.text = split[split.length - 1];
+ for (let i = 0; i < split.length - 1; i++) {
+ result.push({ text: split[i], path: null });
+ }
+ }
+ result.push(element);
+ });
+ return result;
+ }
+
+ isPromise(value: any): boolean {
+ return value && typeof value.then === 'function';
+ }
+
+ wrapIntoObservable<T>(value: T | Promise<T> | Observable<T>): Observable<T> {
+ if (value instanceof Observable) {
+ return value;
+ }
+
+ if (this.isPromise(value)) {
+ return from(Promise.resolve(value));
+ }
+
+ return of(value as T);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html
new file mode 100644
index 000000000..274ec71df
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html
@@ -0,0 +1,25 @@
+<div ngbDropdown
+ placement="bottom-right">
+ <a ngbDropdownToggle
+ i18n-title
+ title="Help">
+ <i [ngClass]="[icons.questionCircle]"></i>
+ <span i18n
+ class="d-md-none">Help</span>
+ </a>
+ <div ngbDropdownMenu>
+ <a ngbDropdownItem
+ [ngClass]="{'disabled': !docsUrl}"
+ class="text-capitalize"
+ href="{{ docsUrl }}"
+ target="_blank"
+ i18n>documentation</a>
+ <a ngbDropdownItem
+ routerLink="/api-docs"
+ target="_blank"
+ i18n>API</a>
+ <button ngbDropdownItem
+ (click)="openAboutModal()"
+ i18n>About</button>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts
new file mode 100644
index 000000000..1c9e0a5f7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts
@@ -0,0 +1,27 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DashboardHelpComponent } from './dashboard-help.component';
+
+describe('DashboardHelpComponent', () => {
+ let component: DashboardHelpComponent;
+ let fixture: ComponentFixture<DashboardHelpComponent>;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule, RouterTestingModule],
+ declarations: [DashboardHelpComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DashboardHelpComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts
new file mode 100644
index 000000000..910a61333
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts
@@ -0,0 +1,31 @@
+import { Component, OnInit } from '@angular/core';
+
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { DocService } from '~/app/shared/services/doc.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { AboutComponent } from '../about/about.component';
+
+@Component({
+ selector: 'cd-dashboard-help',
+ templateUrl: './dashboard-help.component.html',
+ styleUrls: ['./dashboard-help.component.scss']
+})
+export class DashboardHelpComponent implements OnInit {
+ docsUrl: string;
+ modalRef: NgbModalRef;
+ icons = Icons;
+
+ constructor(private modalService: ModalService, private docService: DocService) {}
+
+ ngOnInit() {
+ this.docService.subscribeOnce('dashboard', (url: string) => {
+ this.docsUrl = url;
+ });
+ }
+
+ openAboutModal() {
+ this.modalRef = this.modalService.show(AboutComponent, null, { size: 'lg' });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html
new file mode 100644
index 000000000..bf0f22fbb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html
@@ -0,0 +1,27 @@
+<div ngbDropdown
+ placement="bottom-right">
+ <a ngbDropdownToggle
+ i18n-title
+ title="Logged in user">
+ <i [ngClass]="[icons.user]"></i>
+ <span i18n
+ class="d-md-none">Logged in user</span>
+ </a>
+ <div ngbDropdownMenu>
+ <button ngbDropdownItem
+ disabled
+ i18n>Signed in as <strong>{{ username }}</strong></button>
+ <li class="dropdown-divider"></li>
+ <button ngbDropdownItem
+ *ngIf="!sso"
+ routerLink="/user-profile/edit">
+ <i [ngClass]="[icons.lock]"></i>
+ <span i18n>Change password</span>
+ </button>
+ <button ngbDropdownItem
+ (click)="logout()">
+ <i [ngClass]="[icons.signOut]"></i>
+ <span i18n>Sign out</span>
+ </button>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts
new file mode 100644
index 000000000..23f2f97ca
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts
@@ -0,0 +1,27 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IdentityComponent } from './identity.component';
+
+describe('IdentityComponent', () => {
+ let component: IdentityComponent;
+ let fixture: ComponentFixture<IdentityComponent>;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule, RouterTestingModule],
+ declarations: [IdentityComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(IdentityComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts
new file mode 100644
index 000000000..c1d33b938
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts
@@ -0,0 +1,27 @@
+import { Component, OnInit } from '@angular/core';
+
+import { AuthService } from '~/app/shared/api/auth.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-identity',
+ templateUrl: './identity.component.html',
+ styleUrls: ['./identity.component.scss']
+})
+export class IdentityComponent implements OnInit {
+ sso: boolean;
+ username: string;
+ icons = Icons;
+
+ constructor(private authStorageService: AuthStorageService, private authService: AuthService) {}
+
+ ngOnInit() {
+ this.username = this.authStorageService.getUsername();
+ this.sso = this.authStorageService.isSSO();
+ }
+
+ logout() {
+ this.authService.logout();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts
new file mode 100644
index 000000000..c8d2a9d9c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts
@@ -0,0 +1,43 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { NgbCollapseModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
+import { SimplebarAngularModule } from 'simplebar-angular';
+
+import { AppRoutingModule } from '~/app/app-routing.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { AuthModule } from '../auth/auth.module';
+import { AboutComponent } from './about/about.component';
+import { AdministrationComponent } from './administration/administration.component';
+import { ApiDocsComponent } from './api-docs/api-docs.component';
+import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
+import { DashboardHelpComponent } from './dashboard-help/dashboard-help.component';
+import { IdentityComponent } from './identity/identity.component';
+import { NavigationComponent } from './navigation/navigation.component';
+import { NotificationsComponent } from './notifications/notifications.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ AuthModule,
+ NgbCollapseModule,
+ NgbDropdownModule,
+ AppRoutingModule,
+ SharedModule,
+ SimplebarAngularModule,
+ RouterModule
+ ],
+ declarations: [
+ AboutComponent,
+ ApiDocsComponent,
+ BreadcrumbsComponent,
+ NavigationComponent,
+ NotificationsComponent,
+ DashboardHelpComponent,
+ AdministrationComponent,
+ IdentityComponent
+ ],
+ exports: [NavigationComponent, BreadcrumbsComponent]
+})
+export class NavigationModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
new file mode 100644
index 000000000..bdb35a610
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
@@ -0,0 +1,272 @@
+<div class="cd-navbar-main">
+ <cd-pwd-expiration-notification></cd-pwd-expiration-notification>
+ <cd-telemetry-notification></cd-telemetry-notification>
+ <cd-motd></cd-motd>
+ <cd-notifications-sidebar></cd-notifications-sidebar>
+ <div class="cd-navbar-top">
+ <nav class="navbar navbar-expand-md navbar-dark cd-navbar-brand">
+ <button class="btn btn-link py-0"
+ (click)="showMenuSidebar = !showMenuSidebar"
+ aria-label="toggle sidebar visibility">
+ <i class="fa fa-bars fa-2x"
+ aria-hidden="true"></i>
+ </button>
+
+ <a class="navbar-brand ml-2"
+ href="#">
+ <img src="assets/Ceph_Ceph_Logo_with_text_white.svg"
+ alt="Ceph" />
+ </a>
+
+ <button type="button"
+ class="navbar-toggler"
+ (click)="toggleRightSidebar()">
+ <span i18n
+ class="sr-only">Toggle navigation</span>
+ <span class="">
+ <i class="fa fa-navicon fa-lg"></i>
+ </span>
+ </button>
+
+ <div class="collapse navbar-collapse"
+ [ngClass]="{'show': rightSidebarOpen}">
+ <ul class="nav navbar-nav cd-navbar-utility my-2 my-md-0">
+ <ng-container *ngTemplateOutlet="cd_utilities"> </ng-container>
+ </ul>
+ </div>
+ </nav>
+ </div>
+
+ <div class="wrapper">
+ <!-- Content -->
+ <nav id="sidebar"
+ [ngClass]="{'active': !showMenuSidebar}">
+ <ngx-simplebar [options]="simplebar">
+ <ul class="list-unstyled components cd-navbar-primary">
+ <ng-container *ngTemplateOutlet="cd_menu"> </ng-container>
+ </ul>
+ </ngx-simplebar>
+ </nav>
+
+ <!-- Page Content -->
+ <div id="content"
+ [ngClass]="{'active': !showMenuSidebar}">
+ <ng-content></ng-content>
+ </div>
+ </div>
+
+ <ng-template #cd_utilities>
+ <li class="nav-item">
+ <cd-language-selector class="cd-navbar"></cd-language-selector>
+ </li>
+ <li class="nav-item">
+ <cd-notifications class="cd-navbar"
+ (click)="toggleRightSidebar()"></cd-notifications>
+ </li>
+ <li class="nav-item">
+ <cd-dashboard-help class="cd-navbar"></cd-dashboard-help>
+ </li>
+ <li class="nav-item">
+ <cd-administration class="cd-navbar"></cd-administration>
+ </li>
+ <li class="nav-item">
+ <cd-identity class="cd-navbar"></cd-identity>
+ </li>
+ </ng-template>
+
+ <ng-template #cd_menu>
+ <ng-container *ngIf="enabledFeature$ | async as enabledFeature">
+ <!-- Dashboard -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_dashboard">
+ <a routerLink="/dashboard"
+ class="nav-link">
+ <span i18n>Dashboard</span>&nbsp;
+ <i [ngClass]="[icons.health]"
+ [ngStyle]="summaryData?.health_status | healthColor"></i>
+ </a>
+ </li>
+
+ <!-- Cluster -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_cluster"
+ *ngIf="permissions.hosts.read || permissions.monitor.read ||
+ permissions.osd.read || permissions.configOpt.read ||
+ permissions.log.read || permissions.prometheus.read">
+ <a (click)="toggleSubMenu('cluster')"
+ class="nav-link dropdown-toggle"
+ [attr.aria-expanded]="displayedSubMenu == 'cluster'"
+ aria-controls="collapseBasic">
+ <ng-container i18n>Cluster</ng-container>
+ </a>
+ <ul class="list-unstyled"
+ [ngbCollapse]="displayedSubMenu !== 'cluster'">
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_hosts"
+ *ngIf="permissions.hosts.read">
+ <a i18n
+ routerLink="/hosts">Hosts</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_cluster_inventory"
+ *ngIf="permissions.hosts.read">
+ <a i18n
+ routerLink="/inventory">Physical Disks</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_cluster_monitor"
+ *ngIf="permissions.monitor.read">
+ <a i18n
+ routerLink="/monitor/">Monitors</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_cluster_services"
+ *ngIf="permissions.hosts.read">
+ <a i18n
+ routerLink="/services/">Services</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_osds"
+ *ngIf="permissions.osd.read">
+ <a i18n
+ routerLink="/osd">OSDs</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_configuration"
+ *ngIf="permissions.configOpt.read">
+ <a i18n
+ routerLink="/configuration">Configuration</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_crush"
+ *ngIf="permissions.osd.read">
+ <a i18n
+ routerLink="/crush-map">CRUSH map</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_modules"
+ *ngIf="permissions.configOpt.read">
+ <a i18n
+ routerLink="/mgr-modules">Manager Modules</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_log"
+ *ngIf="permissions.log.read">
+ <a i18n
+ routerLink="/logs">Logs</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_monitoring"
+ *ngIf="permissions.prometheus.read">
+ <a routerLink="/monitoring">
+ <ng-container i18n>Monitoring</ng-container>
+ <small *ngIf="prometheusAlertService.activeAlerts > 0"
+ class="badge badge-danger">{{ prometheusAlertService.activeAlerts }}</small>
+ </a>
+ </li>
+ </ul>
+ </li>
+
+ <!-- Pools -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_pool"
+ *ngIf="permissions.pool.read">
+ <a class="nav-link"
+ i18n
+ routerLink="/pool">Pools</a>
+ </li>
+
+ <!-- Block -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_block"
+ *ngIf="(permissions.rbdImage.read || permissions.rbdMirroring.read || permissions.iscsi.read) &&
+ (enabledFeature.rbd || enabledFeature.mirroring || enabledFeature.iscsi)">
+ <a class="nav-link dropdown-toggle"
+ (click)="toggleSubMenu('block')"
+ [attr.aria-expanded]="displayedSubMenu == 'block'"
+ aria-controls="collapseBasic"
+ [ngStyle]="blockHealthColor()">
+ <ng-container i18n>Block</ng-container>
+ </a>
+
+ <ul class="list-unstyled"
+ [ngbCollapse]="displayedSubMenu !== 'block'">
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_block_images"
+ *ngIf="permissions.rbdImage.read && enabledFeature.rbd">
+ <a i18n
+ routerLink="/block/rbd">Images</a>
+ </li>
+
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_block_mirroring"
+ *ngIf="permissions.rbdMirroring.read && enabledFeature.mirroring">
+ <a routerLink="/block/mirroring">
+ <ng-container i18n>Mirroring</ng-container>
+ <small *ngIf="summaryData?.rbd_mirroring?.warnings !== 0"
+ class="badge badge-warning">{{ summaryData?.rbd_mirroring?.warnings }}</small>
+ <small *ngIf="summaryData?.rbd_mirroring?.errors !== 0"
+ class="badge badge-danger">{{ summaryData?.rbd_mirroring?.errors }}</small>
+ </a>
+ </li>
+
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_block_iscsi"
+ *ngIf="permissions.iscsi.read && enabledFeature.iscsi">
+ <a i18n
+ routerLink="/block/iscsi">iSCSI</a>
+ </li>
+ </ul>
+ </li>
+
+ <!-- NFS -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_nfs"
+ *ngIf="permissions.nfs.read && enabledFeature.nfs">
+ <a i18n
+ class="nav-link"
+ routerLink="/nfs">NFS</a>
+ </li>
+
+ <!-- Filesystem -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_cephfs"
+ *ngIf="permissions.cephfs.read && enabledFeature.cephfs">
+ <a i18n
+ class="nav-link"
+ routerLink="/cephfs">File Systems</a>
+ </li>
+
+ <!-- Object Gateway -->
+ <li routerLinkActive="active"
+ class="nav-item tc_menuitem_rgw"
+ *ngIf="permissions.rgw.read && enabledFeature.rgw">
+ <a class="nav-link dropdown-toggle"
+ (click)="toggleSubMenu('rgw')"
+ [attr.aria-expanded]="displayedSubMenu == 'rgw'"
+ aria-controls="collapseBasic">
+ <ng-container i18n>Object Gateway</ng-container>
+ </a>
+ <ul class="list-unstyled"
+ [ngbCollapse]="displayedSubMenu !== 'rgw'">
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_rgw_daemons">
+ <a i18n
+ routerLink="/rgw/daemon">Daemons</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_rgw_users">
+ <a i18n
+ routerLink="/rgw/user">Users</a>
+ </li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_rgw_buckets">
+ <a i18n
+ routerLink="/rgw/bucket">Buckets</a>
+ </li>
+ </ul>
+ </li>
+ </ng-container>
+ </ng-template>
+
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss
new file mode 100644
index 000000000..f0ce4cd92
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss
@@ -0,0 +1,263 @@
+@use './src/styles/vendor/variables' as vv;
+
+/* --------------------------------------------------
+ MAIN NAVBAR STYLE
+--------------------------------------------------- */
+
+.cd-navbar-main {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ height: 100%;
+}
+
+/* ---------------------------------------------------
+ NAVBAR STYLE
+--------------------------------------------------- */
+
+::ng-deep cd-navigation .cd-navbar-top {
+ .cd-navbar-brand {
+ background: vv.$secondary;
+ border-top: 4px solid vv.$primary;
+
+ .navbar-brand,
+ .navbar-brand:hover {
+ color: vv.$gray-200;
+ height: auto;
+ padding: 0;
+ }
+
+ .navbar-brand > img {
+ height: 25px;
+ }
+
+ .navbar-toggler {
+ border: 0;
+
+ &:focus,
+ &:hover {
+ outline: 0;
+ }
+
+ .fa-navicon {
+ color: vv.$gray-200;
+ }
+ }
+
+ .navbar-collapse {
+ padding: 0;
+ }
+
+ .cd-navbar-utility > .active > a {
+ background-color: vv.$primary;
+ color: vv.$gray-200;
+ }
+
+ .cd-navbar-utility > li > .open > a,
+ .cd-navbar-utility > li > .open > a:focus,
+ .cd-navbar-utility > li > .open > a:hover {
+ background-color: transparent;
+ border-color: transparent;
+ color: vv.$gray-200;
+ }
+ }
+
+ .navbar-nav > li > .cd-navbar > [ngbDropdown] > a,
+ .navbar-nav > li > .cd-navbar > a,
+ .navbar-nav > li > a {
+ color: vv.$gray-200;
+ display: block;
+ line-height: 1;
+ padding: 13.5px 18px !important;
+ position: relative;
+ text-decoration: none;
+ }
+
+ .navbar-nav .nav-link,
+ .navbar-nav .nav-link:hover {
+ color: vv.$gray-200;
+ }
+
+ .navbar-nav > li > .cd-navbar > [ngbDropdown] > a:hover,
+ .navbar-nav > li > .cd-navbar > [ngbDropdown].open > a,
+ .navbar-nav > li > .cd-navbar > a:hover,
+ .navbar-nav > li > a:hover,
+ .navbar-nav > li:hover {
+ background-color: vv.$primary;
+ }
+
+ .navbar-nav > .open > .cd-navbar > [ngbDropdown] > a,
+ .navbar-nav > .open > .cd-navbar > [ngbDropdown] > a:hover,
+ .navbar-nav > .open > .cd-navbar > a,
+ .navbar-nav > .open > .cd-navbar > a:focus,
+ .navbar-nav > .open > .cd-navbar > a:hover,
+ .navbar-nav > .open > .cd-navbar > li > a:focus,
+ .navbar-nav > .open > a,
+ .navbar-nav > .open > a:focus,
+ .navbar-nav > .open > a:hover {
+ background-color: transparent;
+ border-color: transparent;
+ color: vv.$gray-200;
+ }
+
+ @media (min-width: vv.$screen-md-min) {
+ .cd-navbar-utility {
+ border-bottom: 0;
+ font-size: 1.1rem;
+ position: absolute;
+ right: 0;
+ top: 0;
+ }
+ }
+
+ @media (max-width: vv.$screen-sm-max) {
+ .navbar-nav {
+ margin: 0;
+
+ .fa {
+ margin-right: 0.5em;
+ }
+
+ .open .dropdown-menu {
+ background-color: vv.$primary;
+ border: 0;
+ padding-bottom: 0;
+ padding-top: 0;
+ }
+
+ .open .dropdown-menu > li > a {
+ color: vv.$gray-200;
+ padding: 5px 15px 5px 35px;
+ }
+
+ .open .dropdown-menu > .active > a {
+ background-color: vv.$primary;
+ }
+ }
+
+ .navbar-nav > li > a:hover {
+ background-color: vv.$primary;
+ }
+ }
+}
+
+/* ---------------------------------------------------
+ SIDEBAR STYLE
+--------------------------------------------------- */
+
+$sidebar-width: 200px;
+
+.cd-navbar-primary .active > a,
+.cd-navbar-primary > .active > a:focus,
+.cd-navbar-primary > .active > a:hover {
+ background-color: vv.$primary !important;
+ border: 0 !important;
+ color: vv.$gray-200 !important;
+}
+
+.wrapper {
+ display: flex;
+ height: 100%;
+ width: 100%;
+
+ #sidebar {
+ background: vv.$secondary;
+ bottom: 0;
+ color: vv.$white;
+ height: auto;
+ left: 0;
+ overflow-y: auto;
+ position: relative;
+ transition: all 0.3s;
+ width: $sidebar-width;
+ z-index: 999;
+
+ &.active {
+ margin-left: -$sidebar-width;
+ }
+
+ ul {
+ &.component {
+ margin: 0;
+ padding: 20px 0;
+ }
+
+ p {
+ color: vv.$white;
+ padding: 10px;
+ }
+
+ li a {
+ color: vv.$white;
+ display: block;
+ font-size: 1.1em;
+ padding: 10px;
+ padding-left: 27px;
+
+ text-decoration: none;
+
+ &:hover {
+ background: vv.$primary;
+ color: vv.$white;
+ }
+
+ > .badge {
+ margin-left: 5px;
+ }
+ }
+
+ li.active > a,
+ li > a a[aria-expanded='true'] {
+ color: vv.$white;
+ }
+ }
+ }
+
+ a.dropdown-toggle {
+ position: relative;
+
+ &::after {
+ border: 0;
+ content: '\f054';
+ font-family: 'ForkAwesome';
+ font-size: 1rem;
+ position: absolute;
+ right: 20px;
+ transition: transform 0.3s ease-in-out;
+ }
+
+ &[aria-expanded='true']::after {
+ transform: rotate(90deg);
+ }
+ }
+
+ ul ul a {
+ background: lighten(vv.$secondary, 10);
+ font-size: 0.9em !important;
+ padding-left: 40px !important;
+ }
+
+ .cd-navbar-primary a:focus {
+ outline: none;
+ }
+
+ ngx-simplebar {
+ height: 100%;
+ }
+}
+
+/* ---------------------------------------------------
+ CONTENT STYLE
+--------------------------------------------------- */
+
+#content {
+ bottom: 0;
+ position: relative;
+ right: 0;
+ transition: all 0.3s;
+ width: calc(100% - #{$sidebar-width});
+
+ &.active {
+ width: 100vw;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
new file mode 100644
index 000000000..241910f2b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
@@ -0,0 +1,237 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { MockModule } from 'ng-mocks';
+import { of } from 'rxjs';
+
+import { Permission, Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import {
+ Features,
+ FeatureTogglesMap,
+ FeatureTogglesService
+} from '~/app/shared/services/feature-toggles.service';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NavigationModule } from '../navigation.module';
+import { NavigationComponent } from './navigation.component';
+
+function everythingPermittedExcept(disabledPermissions: string[] = []): any {
+ const permissions: Permissions = new Permissions({});
+ Object.keys(permissions).forEach(
+ (key) => (permissions[key] = new Permission(disabledPermissions.includes(key) ? [] : ['read']))
+ );
+ return permissions;
+}
+
+function onlyPermitted(enabledPermissions: string[] = []): any {
+ const permissions: Permissions = new Permissions({});
+ enabledPermissions.forEach((key) => (permissions[key] = new Permission(['read'])));
+ return permissions;
+}
+
+function everythingEnabledExcept(features: Features[] = []): FeatureTogglesMap {
+ const featureTogglesMap: FeatureTogglesMap = new FeatureTogglesMap();
+ features.forEach((key) => (featureTogglesMap[key] = false));
+ return featureTogglesMap;
+}
+
+function onlyEnabled(features: Features[] = []): FeatureTogglesMap {
+ const featureTogglesMap: FeatureTogglesMap = new FeatureTogglesMap();
+ Object.keys(featureTogglesMap).forEach(
+ (key) => (featureTogglesMap[key] = features.includes(<Features>key))
+ );
+ return featureTogglesMap;
+}
+
+describe('NavigationComponent', () => {
+ let component: NavigationComponent;
+ let fixture: ComponentFixture<NavigationComponent>;
+
+ configureTestBed({
+ declarations: [NavigationComponent],
+ imports: [HttpClientTestingModule, MockModule(NavigationModule)],
+ providers: [
+ {
+ provide: AuthStorageService,
+ useValue: {
+ getPermissions: jest.fn(),
+ isPwdDisplayed$: { subscribe: jest.fn() },
+ telemetryNotification$: { subscribe: jest.fn() }
+ }
+ },
+ { provide: SummaryService, useValue: { subscribe: jest.fn() } },
+ { provide: FeatureTogglesService, useValue: { get: jest.fn() } },
+ { provide: PrometheusAlertService, useValue: { alerts: [] } }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NavigationComponent);
+ component = fixture.componentInstance;
+ });
+
+ describe('Test Permissions', () => {
+ const testCases: [string[], string[]][] = [
+ [
+ ['hosts'],
+ [
+ '.tc_submenuitem_hosts',
+ '.tc_submenuitem_cluster_inventory',
+ '.tc_submenuitem_cluster_services'
+ ]
+ ],
+ [['monitor'], ['.tc_submenuitem_cluster_monitor']],
+ [['osd'], ['.tc_submenuitem_osds', '.tc_submenuitem_crush']],
+ [['configOpt'], ['.tc_submenuitem_configuration', '.tc_submenuitem_modules']],
+ [['log'], ['.tc_submenuitem_log']],
+ [['prometheus'], ['.tc_submenuitem_monitoring']],
+ [['pool'], ['.tc_menuitem_pool']],
+ [['rbdImage'], ['.tc_submenuitem_block_images']],
+ [['rbdMirroring'], ['.tc_submenuitem_block_mirroring']],
+ [['iscsi'], ['.tc_submenuitem_block_iscsi']],
+ [['rbdImage', 'rbdMirroring', 'iscsi'], ['.tc_menuitem_block']],
+ [['nfs'], ['.tc_menuitem_nfs']],
+ [['cephfs'], ['.tc_menuitem_cephfs']],
+ [
+ ['rgw'],
+ [
+ '.tc_menuitem_rgw',
+ '.tc_submenuitem_rgw_daemons',
+ '.tc_submenuitem_rgw_buckets',
+ '.tc_submenuitem_rgw_users'
+ ]
+ ]
+ ];
+
+ for (const [disabledPermissions, selectors] of testCases) {
+ it(`When disabled permissions: ${JSON.stringify(
+ disabledPermissions
+ )} => hidden: "${selectors}"`, () => {
+ component.permissions = everythingPermittedExcept(disabledPermissions);
+ component.enabledFeature$ = of(everythingEnabledExcept());
+
+ fixture.detectChanges();
+ for (const selector of selectors) {
+ expect(fixture.debugElement.query(By.css(selector))).toBeFalsy();
+ }
+ });
+ }
+
+ for (const [enabledPermissions, selectors] of testCases) {
+ it(`When enabled permissions: ${JSON.stringify(
+ enabledPermissions
+ )} => visible: "${selectors}"`, () => {
+ component.permissions = onlyPermitted(enabledPermissions);
+ component.enabledFeature$ = of(everythingEnabledExcept());
+
+ fixture.detectChanges();
+ for (const selector of selectors) {
+ expect(fixture.debugElement.query(By.css(selector))).toBeTruthy();
+ }
+ });
+ }
+ });
+
+ describe('Test FeatureToggles', () => {
+ const testCases: [Features[], string[]][] = [
+ [['rbd'], ['.tc_submenuitem_block_images']],
+ [['mirroring'], ['.tc_submenuitem_block_mirroring']],
+ [['iscsi'], ['.tc_submenuitem_block_iscsi']],
+ [['rbd', 'mirroring', 'iscsi'], ['.tc_menuitem_block']],
+ [['nfs'], ['.tc_menuitem_nfs']],
+ [['cephfs'], ['.tc_menuitem_cephfs']],
+ [
+ ['rgw'],
+ [
+ '.tc_menuitem_rgw',
+ '.tc_submenuitem_rgw_daemons',
+ '.tc_submenuitem_rgw_buckets',
+ '.tc_submenuitem_rgw_users'
+ ]
+ ]
+ ];
+
+ for (const [disabledFeatures, selectors] of testCases) {
+ it(`When disabled features: ${JSON.stringify(
+ disabledFeatures
+ )} => hidden: "${selectors}"`, () => {
+ component.enabledFeature$ = of(everythingEnabledExcept(disabledFeatures));
+ component.permissions = everythingPermittedExcept();
+
+ fixture.detectChanges();
+ for (const selector of selectors) {
+ expect(fixture.debugElement.query(By.css(selector))).toBeFalsy();
+ }
+ });
+ }
+
+ for (const [enabledFeatures, selectors] of testCases) {
+ it(`When enabled features: ${JSON.stringify(
+ enabledFeatures
+ )} => visible: "${selectors}"`, () => {
+ component.enabledFeature$ = of(onlyEnabled(enabledFeatures));
+ component.permissions = everythingPermittedExcept();
+
+ fixture.detectChanges();
+ for (const selector of selectors) {
+ expect(fixture.debugElement.query(By.css(selector))).toBeTruthy();
+ }
+ });
+ }
+ });
+
+ describe('showTopNotification', () => {
+ const notification1 = 'notificationName1';
+ const notification2 = 'notificationName2';
+
+ beforeEach(() => {
+ component.notifications = [];
+ });
+
+ it('should show notification', () => {
+ component.showTopNotification(notification1, true);
+ expect(component.notifications.includes(notification1)).toBeTruthy();
+ expect(component.notifications.length).toBe(1);
+ });
+
+ it('should not add a second notification if it is already shown', () => {
+ component.showTopNotification(notification1, true);
+ component.showTopNotification(notification1, true);
+ expect(component.notifications.includes(notification1)).toBeTruthy();
+ expect(component.notifications.length).toBe(1);
+ });
+
+ it('should add a second notification if the first one is different', () => {
+ component.showTopNotification(notification1, true);
+ component.showTopNotification(notification2, true);
+ expect(component.notifications.includes(notification1)).toBeTruthy();
+ expect(component.notifications.includes(notification2)).toBeTruthy();
+ expect(component.notifications.length).toBe(2);
+ });
+
+ it('should hide an active notification', () => {
+ component.showTopNotification(notification1, true);
+ expect(component.notifications.includes(notification1)).toBeTruthy();
+ expect(component.notifications.length).toBe(1);
+ component.showTopNotification(notification1, false);
+ expect(component.notifications.length).toBe(0);
+ });
+
+ it('should not fail if it tries to hide an inactive notification', () => {
+ expect(() => component.showTopNotification(notification1, false)).not.toThrow();
+ expect(component.notifications.length).toBe(0);
+ });
+
+ it('should keep other notifications if it hides one', () => {
+ component.showTopNotification(notification1, true);
+ component.showTopNotification(notification2, true);
+ expect(component.notifications.length).toBe(2);
+ component.showTopNotification(notification2, false);
+ expect(component.notifications.length).toBe(1);
+ expect(component.notifications.includes(notification1)).toBeTruthy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
new file mode 100644
index 000000000..512feecef
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
@@ -0,0 +1,123 @@
+import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
+
+import * as _ from 'lodash';
+import { Subscription } from 'rxjs';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import {
+ FeatureTogglesMap$,
+ FeatureTogglesService
+} from '~/app/shared/services/feature-toggles.service';
+import { MotdNotificationService } from '~/app/shared/services/motd-notification.service';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service';
+
+@Component({
+ selector: 'cd-navigation',
+ templateUrl: './navigation.component.html',
+ styleUrls: ['./navigation.component.scss']
+})
+export class NavigationComponent implements OnInit, OnDestroy {
+ notifications: string[] = [];
+ @HostBinding('class') get class(): string {
+ return 'top-notification-' + this.notifications.length;
+ }
+
+ permissions: Permissions;
+ enabledFeature$: FeatureTogglesMap$;
+ summaryData: any;
+ icons = Icons;
+
+ rightSidebarOpen = false; // rightSidebar only opens when width is less than 768px
+ showMenuSidebar = true;
+ displayedSubMenu = '';
+
+ simplebar = {
+ autoHide: false
+ };
+ private subs = new Subscription();
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private summaryService: SummaryService,
+ private featureToggles: FeatureTogglesService,
+ private telemetryNotificationService: TelemetryNotificationService,
+ public prometheusAlertService: PrometheusAlertService,
+ private motdNotificationService: MotdNotificationService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ this.enabledFeature$ = this.featureToggles.get();
+ }
+
+ ngOnInit() {
+ this.subs.add(
+ this.summaryService.subscribe((summary) => {
+ this.summaryData = summary;
+ })
+ );
+ /*
+ Note: If you're going to add more top notifications please do not forget to increase
+ the number of generated css-classes in section topNotification settings in the scss
+ file.
+ */
+ this.subs.add(
+ this.authStorageService.isPwdDisplayed$.subscribe((isDisplayed) => {
+ this.showTopNotification('isPwdDisplayed', isDisplayed);
+ })
+ );
+ this.subs.add(
+ this.telemetryNotificationService.update.subscribe((visible: boolean) => {
+ this.showTopNotification('telemetryNotificationEnabled', visible);
+ })
+ );
+ this.subs.add(
+ this.motdNotificationService.motd$.subscribe((motd: any) => {
+ this.showTopNotification('motdNotificationEnabled', _.isPlainObject(motd));
+ })
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ blockHealthColor() {
+ if (this.summaryData && this.summaryData.rbd_mirroring) {
+ if (this.summaryData.rbd_mirroring.errors > 0) {
+ return { color: '#d9534f' };
+ } else if (this.summaryData.rbd_mirroring.warnings > 0) {
+ return { color: '#f0ad4e' };
+ }
+ }
+
+ return undefined;
+ }
+
+ toggleSubMenu(menu: string) {
+ if (this.displayedSubMenu === menu) {
+ this.displayedSubMenu = '';
+ } else {
+ this.displayedSubMenu = menu;
+ }
+ }
+
+ toggleRightSidebar() {
+ this.rightSidebarOpen = !this.rightSidebarOpen;
+ }
+
+ showTopNotification(name: string, isDisplayed: boolean) {
+ if (isDisplayed) {
+ if (!this.notifications.includes(name)) {
+ this.notifications.push(name);
+ }
+ } else {
+ const index = this.notifications.indexOf(name);
+ if (index >= 0) {
+ this.notifications.splice(index, 1);
+ }
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
new file mode 100644
index 000000000..f5eae4f89
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
@@ -0,0 +1,11 @@
+<a i18n-title
+ title="Tasks and Notifications"
+ [ngClass]="{ 'running': hasRunningTasks }"
+ (click)="toggleSidebar()">
+ <i [ngClass]="[icons.bell]"></i>
+ <span class="dot"
+ *ngIf="hasNotifications">
+ </span>
+ <span class="d-md-none"
+ i18n>Tasks and Notifications</span>
+</a>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss
new file mode 100644
index 000000000..5729f7625
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss
@@ -0,0 +1,27 @@
+@use './src/styles/vendor/variables' as vv;
+
+.running i {
+ color: vv.$primary;
+}
+
+.running:hover i {
+ color: vv.$white;
+}
+
+a {
+ .dot {
+ background-color: vv.$primary;
+ border: 2px solid vv.$secondary;
+ border-radius: 50%;
+ height: 11px;
+ position: absolute;
+ right: 17px;
+ top: 10px;
+ width: 10px;
+ }
+
+ &:hover .dot {
+ background-color: vv.$white;
+ border-color: vv.$primary;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts
new file mode 100644
index 000000000..8fea818cf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts
@@ -0,0 +1,58 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { CdNotification, CdNotificationConfig } from '~/app/shared/models/cd-notification';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NotificationsComponent } from './notifications.component';
+
+describe('NotificationsComponent', () => {
+ let component: NotificationsComponent;
+ let fixture: ComponentFixture<NotificationsComponent>;
+ let summaryService: SummaryService;
+ let notificationService: NotificationService;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule, ToastrModule.forRoot(), RouterTestingModule],
+ declarations: [NotificationsComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NotificationsComponent);
+ component = fixture.componentInstance;
+ summaryService = TestBed.inject(SummaryService);
+ notificationService = TestBed.inject(NotificationService);
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should subscribe and check if there are running tasks', () => {
+ expect(component.hasRunningTasks).toBeFalsy();
+
+ const task = new ExecutingTask('task', { name: 'name' });
+ summaryService['summaryDataSource'].next({ executing_tasks: [task] });
+
+ expect(component.hasRunningTasks).toBeTruthy();
+ });
+
+ it('should create a dot if there are running notifications', () => {
+ const notification = new CdNotification(new CdNotificationConfig());
+ const recent = notificationService['dataSource'].getValue();
+ recent.push(notification);
+ notificationService['dataSource'].next(recent);
+ expect(component.hasNotifications).toBeTruthy();
+ fixture.detectChanges();
+ const dot = fixture.debugElement.nativeElement.querySelector('.dot');
+ expect(dot).not.toBe('');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
new file mode 100644
index 000000000..89c6c4037
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
@@ -0,0 +1,47 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdNotification } from '~/app/shared/models/cd-notification';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+
+@Component({
+ selector: 'cd-notifications',
+ templateUrl: './notifications.component.html',
+ styleUrls: ['./notifications.component.scss']
+})
+export class NotificationsComponent implements OnInit, OnDestroy {
+ icons = Icons;
+ hasRunningTasks = false;
+ hasNotifications = false;
+ private subs = new Subscription();
+
+ constructor(
+ public notificationService: NotificationService,
+ private summaryService: SummaryService
+ ) {}
+
+ ngOnInit() {
+ this.subs.add(
+ this.summaryService.subscribe((summary) => {
+ this.hasRunningTasks = summary.executing_tasks.length > 0;
+ })
+ );
+
+ this.subs.add(
+ this.notificationService.data$.subscribe((notifications: CdNotification[]) => {
+ this.hasNotifications = notifications.length > 0;
+ })
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ toggleSidebar() {
+ this.notificationService.toggleSidebar();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts
new file mode 100644
index 000000000..0d521a889
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts
@@ -0,0 +1,11 @@
+import { ApiClient } from '~/app/shared/api/api-client';
+
+class MockApiClient extends ApiClient {}
+
+describe('ApiClient', () => {
+ const service = new MockApiClient();
+
+ it('should get the version header value', () => {
+ expect(service.getVersionHeaderValue(1, 2)).toBe('application/vnd.ceph.api.v1.2+json');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts
new file mode 100644
index 000000000..06583eb10
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts
@@ -0,0 +1,5 @@
+export abstract class ApiClient {
+ getVersionHeaderValue(major: number, minor: number) {
+ return `application/vnd.ceph.api.v${major}.${minor}+json`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts
new file mode 100644
index 000000000..c32f0ea05
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts
@@ -0,0 +1,57 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AuthStorageService } from '../services/auth-storage.service';
+import { AuthService } from './auth.service';
+
+describe('AuthService', () => {
+ let service: AuthService;
+ let httpTesting: HttpTestingController;
+
+ const routes: Routes = [{ path: 'login', children: [] }];
+
+ configureTestBed({
+ providers: [AuthService, AuthStorageService],
+ imports: [HttpClientTestingModule, RouterTestingModule.withRoutes(routes)]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(AuthService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should login and save the user', fakeAsync(() => {
+ const fakeCredentials = { username: 'foo', password: 'bar' };
+ const fakeResponse = { username: 'foo' };
+ service.login(fakeCredentials).subscribe();
+ const req = httpTesting.expectOne('api/auth');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(fakeCredentials);
+ req.flush(fakeResponse);
+ tick();
+ expect(localStorage.getItem('dashboard_username')).toBe('foo');
+ }));
+
+ it('should logout and remove the user', () => {
+ const router = TestBed.inject(Router);
+ spyOn(router, 'navigate').and.stub();
+
+ service.logout();
+ const req = httpTesting.expectOne('api/auth/logout');
+ expect(req.request.method).toBe('POST');
+ req.flush({ redirect_url: '#/login' });
+ expect(localStorage.getItem('dashboard_username')).toBe(null);
+ expect(router.navigate).toBeCalledTimes(1);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts
new file mode 100644
index 000000000..8a2917992
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts
@@ -0,0 +1,53 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import * as _ from 'lodash';
+import { Observable } from 'rxjs';
+import { tap } from 'rxjs/operators';
+
+import { Credentials } from '../models/credentials';
+import { LoginResponse } from '../models/login-response';
+import { AuthStorageService } from '../services/auth-storage.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AuthService {
+ constructor(
+ private authStorageService: AuthStorageService,
+ private http: HttpClient,
+ private router: Router,
+ private route: ActivatedRoute
+ ) {}
+
+ check(token: string) {
+ return this.http.post('api/auth/check', { token: token });
+ }
+
+ login(credentials: Credentials): Observable<LoginResponse> {
+ return this.http.post('api/auth', credentials).pipe(
+ tap((resp: LoginResponse) => {
+ this.authStorageService.set(
+ resp.username,
+ resp.permissions,
+ resp.sso,
+ resp.pwdExpirationDate,
+ resp.pwdUpdateRequired
+ );
+ })
+ );
+ }
+
+ logout(callback: Function = null) {
+ return this.http.post('api/auth/logout', null).subscribe((resp: any) => {
+ this.authStorageService.remove();
+ const url = _.get(this.route.snapshot.queryParams, 'returnUrl', '/login');
+ this.router.navigate([url], { skipLocationChange: true });
+ if (callback) {
+ callback();
+ }
+ window.location.replace(resp.redirect_url);
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts
new file mode 100644
index 000000000..c62dfea7c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts
@@ -0,0 +1,63 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+import { Daemon } from '../models/daemon.interface';
+import { CephServiceSpec } from '../models/service.interface';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CephServiceService {
+ private url = 'api/service';
+
+ constructor(private http: HttpClient) {}
+
+ list(serviceName?: string): Observable<CephServiceSpec[]> {
+ const options = serviceName
+ ? { params: new HttpParams().set('service_name', serviceName) }
+ : {};
+ return this.http.get<CephServiceSpec[]>(this.url, options);
+ }
+
+ getDaemons(serviceName?: string): Observable<Daemon[]> {
+ return this.http.get<Daemon[]>(`${this.url}/${serviceName}/daemons`);
+ }
+
+ create(serviceSpec: { [key: string]: any }) {
+ const serviceName = serviceSpec['service_id']
+ ? `${serviceSpec['service_type']}.${serviceSpec['service_id']}`
+ : serviceSpec['service_type'];
+ return this.http.post(
+ this.url,
+ {
+ service_name: serviceName,
+ service_spec: serviceSpec
+ },
+ { observe: 'response' }
+ );
+ }
+
+ update(serviceSpec: { [key: string]: any }) {
+ const serviceName = serviceSpec['service_id']
+ ? `${serviceSpec['service_type']}.${serviceSpec['service_id']}`
+ : serviceSpec['service_type'];
+ return this.http.put(
+ `${this.url}/${serviceName}`,
+ {
+ service_name: serviceName,
+ service_spec: serviceSpec
+ },
+ { observe: 'response' }
+ );
+ }
+
+ delete(serviceName: string) {
+ return this.http.delete(`${this.url}/${serviceName}`, { observe: 'response' });
+ }
+
+ getKnownTypes(): Observable<string[]> {
+ return this.http.get<string[]>(`${this.url}/known_types`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
new file mode 100644
index 000000000..58395cd67
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
@@ -0,0 +1,98 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CephfsService } from './cephfs.service';
+
+describe('CephfsService', () => {
+ let service: CephfsService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [CephfsService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(CephfsService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/cephfs');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getCephfs', () => {
+ service.getCephfs(1).subscribe();
+ const req = httpTesting.expectOne('api/cephfs/1');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getClients', () => {
+ service.getClients(1).subscribe();
+ const req = httpTesting.expectOne('api/cephfs/1/clients');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getTabs', () => {
+ service.getTabs(2).subscribe();
+ const req = httpTesting.expectOne('ui-api/cephfs/2/tabs');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getMdsCounters', () => {
+ service.getMdsCounters('1').subscribe();
+ const req = httpTesting.expectOne('api/cephfs/1/mds_counters');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call lsDir', () => {
+ service.lsDir(1).subscribe();
+ const req = httpTesting.expectOne('ui-api/cephfs/1/ls_dir?depth=2');
+ expect(req.request.method).toBe('GET');
+ service.lsDir(2, '/some/path').subscribe();
+ httpTesting.expectOne('ui-api/cephfs/2/ls_dir?depth=2&path=%252Fsome%252Fpath');
+ });
+
+ it('should call mkSnapshot', () => {
+ service.mkSnapshot(3, '/some/path').subscribe();
+ const req = httpTesting.expectOne('api/cephfs/3/snapshot?path=%252Fsome%252Fpath');
+ expect(req.request.method).toBe('POST');
+
+ service.mkSnapshot(4, '/some/other/path', 'snap').subscribe();
+ httpTesting.expectOne('api/cephfs/4/snapshot?path=%252Fsome%252Fother%252Fpath&name=snap');
+ });
+
+ it('should call rmSnapshot', () => {
+ service.rmSnapshot(1, '/some/path', 'snap').subscribe();
+ const req = httpTesting.expectOne('api/cephfs/1/snapshot?path=%252Fsome%252Fpath&name=snap');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call updateQuota', () => {
+ service.quota(1, '/some/path', { max_bytes: 1024 }).subscribe();
+ let req = httpTesting.expectOne('api/cephfs/1/quota?path=%252Fsome%252Fpath');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ max_bytes: 1024 });
+
+ service.quota(1, '/some/path', { max_files: 10 }).subscribe();
+ req = httpTesting.expectOne('api/cephfs/1/quota?path=%252Fsome%252Fpath');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ max_files: 10 });
+
+ service.quota(1, '/some/path', { max_bytes: 1024, max_files: 10 }).subscribe();
+ req = httpTesting.expectOne('api/cephfs/1/quota?path=%252Fsome%252Fpath');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ max_bytes: 1024, max_files: 10 });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
new file mode 100644
index 000000000..02f31ca7b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
@@ -0,0 +1,76 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+
+import { cdEncode } from '../decorators/cd-encode';
+import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class CephfsService {
+ baseURL = 'api/cephfs';
+ baseUiURL = 'ui-api/cephfs';
+
+ constructor(private http: HttpClient) {}
+
+ list() {
+ return this.http.get(`${this.baseURL}`);
+ }
+
+ lsDir(id: number, path?: string): Observable<CephfsDir[]> {
+ let apiPath = `${this.baseUiURL}/${id}/ls_dir?depth=2`;
+ if (path) {
+ apiPath += `&path=${encodeURIComponent(path)}`;
+ }
+ return this.http.get<CephfsDir[]>(apiPath);
+ }
+
+ getCephfs(id: number) {
+ return this.http.get(`${this.baseURL}/${id}`);
+ }
+
+ getTabs(id: number) {
+ return this.http.get(`ui-api/cephfs/${id}/tabs`);
+ }
+
+ getClients(id: number) {
+ return this.http.get(`${this.baseURL}/${id}/clients`);
+ }
+
+ evictClient(fsId: number, clientId: number) {
+ return this.http.delete(`${this.baseURL}/${fsId}/client/${clientId}`);
+ }
+
+ getMdsCounters(id: string) {
+ return this.http.get(`${this.baseURL}/${id}/mds_counters`);
+ }
+
+ mkSnapshot(id: number, path: string, name?: string) {
+ let params = new HttpParams();
+ params = params.append('path', path);
+ if (!_.isUndefined(name)) {
+ params = params.append('name', name);
+ }
+ return this.http.post(`${this.baseURL}/${id}/snapshot`, null, { params });
+ }
+
+ rmSnapshot(id: number, path: string, name: string) {
+ let params = new HttpParams();
+ params = params.append('path', path);
+ params = params.append('name', name);
+ return this.http.delete(`${this.baseURL}/${id}/snapshot`, { params });
+ }
+
+ quota(id: number, path: string, quotas: CephfsQuotas) {
+ let params = new HttpParams();
+ params = params.append('path', path);
+ return this.http.put(`${this.baseURL}/${id}/quota`, quotas, {
+ observe: 'response',
+ params
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts
new file mode 100644
index 000000000..758f670ee
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ClusterService } from './cluster.service';
+
+describe('ClusterService', () => {
+ let service: ClusterService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [ClusterService]
+ });
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(ClusterService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getStatus', () => {
+ service.getStatus().subscribe();
+ const req = httpTesting.expectOne('api/cluster');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should update cluster status', fakeAsync(() => {
+ service.updateStatus('fakeStatus').subscribe();
+ const req = httpTesting.expectOne('api/cluster');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ status: 'fakeStatus' });
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts
new file mode 100644
index 000000000..6b435d6ff
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts
@@ -0,0 +1,27 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ClusterService {
+ baseURL = 'api/cluster';
+
+ constructor(private http: HttpClient) {}
+
+ getStatus(): Observable<string> {
+ return this.http.get<string>(`${this.baseURL}`, {
+ headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }
+ });
+ }
+
+ updateStatus(status: string) {
+ return this.http.put(
+ `${this.baseURL}`,
+ { status: status },
+ { headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts
new file mode 100644
index 000000000..da05957a4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts
@@ -0,0 +1,99 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { ConfigFormCreateRequestModel } from '~/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ConfigurationService } from './configuration.service';
+
+describe('ConfigurationService', () => {
+ let service: ConfigurationService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [ConfigurationService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(ConfigurationService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getConfigData', () => {
+ service.getConfigData().subscribe();
+ const req = httpTesting.expectOne('api/cluster_conf/');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call get', () => {
+ service.get('configOption').subscribe();
+ const req = httpTesting.expectOne('api/cluster_conf/configOption');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call create', () => {
+ const configOption = new ConfigFormCreateRequestModel();
+ configOption.name = 'Test option';
+ configOption.value = [
+ { section: 'section1', value: 'value1' },
+ { section: 'section2', value: 'value2' }
+ ];
+ service.create(configOption).subscribe();
+ const req = httpTesting.expectOne('api/cluster_conf/');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(configOption);
+ });
+
+ it('should call bulkCreate', () => {
+ const configOptions = {
+ configOption1: { section: 'section', value: 'value' },
+ configOption2: { section: 'section', value: 'value' }
+ };
+ service.bulkCreate(configOptions).subscribe();
+ const req = httpTesting.expectOne('api/cluster_conf/');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(configOptions);
+ });
+
+ it('should call filter', () => {
+ const configOptions = ['configOption1', 'configOption2', 'configOption3'];
+ service.filter(configOptions).subscribe();
+ const req = httpTesting.expectOne(
+ 'api/cluster_conf/filter?names=configOption1,configOption2,configOption3'
+ );
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call delete', () => {
+ service.delete('testOption', 'testSection').subscribe();
+ const reg = httpTesting.expectOne('api/cluster_conf/testOption?section=testSection');
+ expect(reg.request.method).toBe('DELETE');
+ });
+
+ it('should get value', () => {
+ const config = {
+ default: 'a',
+ value: [
+ { section: 'global', value: 'b' },
+ { section: 'mon', value: 'c' },
+ { section: 'mon.1', value: 'd' },
+ { section: 'mds', value: 'e' }
+ ]
+ };
+ expect(service.getValue(config, 'mon.1')).toBe('d');
+ expect(service.getValue(config, 'mon')).toBe('c');
+ expect(service.getValue(config, 'mds.1')).toBe('e');
+ expect(service.getValue(config, 'mds')).toBe('e');
+ expect(service.getValue(config, 'osd')).toBe('b');
+ config.value = [];
+ expect(service.getValue(config, 'osd')).toBe('a');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts
new file mode 100644
index 000000000..5bad098c9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts
@@ -0,0 +1,59 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { ConfigFormCreateRequestModel } from '~/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ConfigurationService {
+ constructor(private http: HttpClient) {}
+
+ private findValue(config: any, section: string) {
+ if (!config.value) {
+ return undefined;
+ }
+ return config.value.find((v: any) => v.section === section);
+ }
+
+ getValue(config: any, section: string) {
+ let val = this.findValue(config, section);
+ if (!val) {
+ const indexOfDot = section.indexOf('.');
+ if (indexOfDot !== -1) {
+ val = this.findValue(config, section.substring(0, indexOfDot));
+ }
+ }
+ if (!val) {
+ val = this.findValue(config, 'global');
+ }
+ if (val) {
+ return val.value;
+ }
+ return config.default;
+ }
+
+ getConfigData() {
+ return this.http.get('api/cluster_conf/');
+ }
+
+ get(configOption: string) {
+ return this.http.get(`api/cluster_conf/${configOption}`);
+ }
+
+ filter(configOptionNames: Array<string>) {
+ return this.http.get(`api/cluster_conf/filter?names=${configOptionNames.join(',')}`);
+ }
+
+ create(configOption: ConfigFormCreateRequestModel) {
+ return this.http.post('api/cluster_conf/', configOption);
+ }
+
+ delete(configOption: string, section: string) {
+ return this.http.delete(`api/cluster_conf/${configOption}?section=${section}`);
+ }
+
+ bulkCreate(configOptions: object) {
+ return this.http.put('api/cluster_conf/', configOptions);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts
new file mode 100644
index 000000000..1142e5368
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts
@@ -0,0 +1,47 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CrushRuleService } from './crush-rule.service';
+
+describe('CrushRuleService', () => {
+ let service: CrushRuleService;
+ let httpTesting: HttpTestingController;
+ const apiPath = 'api/crush_rule';
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [CrushRuleService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(CrushRuleService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call create', () => {
+ service.create({ root: 'default', name: 'someRule', failure_domain: 'osd' }).subscribe();
+ const req = httpTesting.expectOne(apiPath);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call delete', () => {
+ service.delete('test').subscribe();
+ const req = httpTesting.expectOne(`${apiPath}/test`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call getInfo', () => {
+ service.getInfo().subscribe();
+ const req = httpTesting.expectOne(`ui-${apiPath}/info`);
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts
new file mode 100644
index 000000000..e4e7bb605
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts
@@ -0,0 +1,32 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { CrushRuleConfig } from '../models/crush-rule';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CrushRuleService {
+ apiPath = 'api/crush_rule';
+
+ formTooltips = {
+ // Copied from /doc/rados/operations/crush-map.rst
+ root: $localize`The name of the node under which data should be placed.`,
+ failure_domain: $localize`The type of CRUSH nodes across which we should separate replicas.`,
+ device_class: $localize`The device class data should be placed on.`
+ };
+
+ constructor(private http: HttpClient) {}
+
+ create(rule: CrushRuleConfig) {
+ return this.http.post(this.apiPath, rule, { observe: 'response' });
+ }
+
+ delete(name: string) {
+ return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' });
+ }
+
+ getInfo() {
+ return this.http.get(`ui-${this.apiPath}/info`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts
new file mode 100644
index 000000000..d1db441c7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts
@@ -0,0 +1,35 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CustomLoginBannerService } from './custom-login-banner.service';
+
+describe('CustomLoginBannerService', () => {
+ let service: CustomLoginBannerService;
+ let httpTesting: HttpTestingController;
+ const baseUiURL = 'ui-api/login/custom_banner';
+
+ configureTestBed({
+ providers: [CustomLoginBannerService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(CustomLoginBannerService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getBannerText', () => {
+ service.getBannerText().subscribe();
+ const req = httpTesting.expectOne(baseUiURL);
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts
new file mode 100644
index 000000000..7c499eb13
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts
@@ -0,0 +1,15 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CustomLoginBannerService {
+ baseUiURL = 'ui-api/login/custom_banner';
+
+ constructor(private http: HttpClient) {}
+
+ getBannerText() {
+ return this.http.get<string>(this.baseUiURL);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts
new file mode 100644
index 000000000..787e5db7c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts
@@ -0,0 +1,39 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DaemonService } from './daemon.service';
+
+describe('DaemonService', () => {
+ let service: DaemonService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [DaemonService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(DaemonService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call action', () => {
+ const put_data: any = {
+ action: 'start',
+ container_image: null
+ };
+ service.action('osd.1', 'start').subscribe();
+ const req = httpTesting.expectOne('api/daemon/osd.1');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(put_data);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts
new file mode 100644
index 000000000..a66ed7edb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts
@@ -0,0 +1,28 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class DaemonService {
+ private url = 'api/daemon';
+
+ constructor(private http: HttpClient) {}
+
+ action(daemonName: string, actionType: string) {
+ return this.http.put(
+ `${this.url}/${daemonName}`,
+ {
+ action: actionType,
+ container_image: null
+ },
+ {
+ headers: { Accept: 'application/vnd.ceph.api.v0.1+json' },
+ observe: 'response'
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts
new file mode 100644
index 000000000..caf3da0c6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts
@@ -0,0 +1,55 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ErasureCodeProfile } from '../models/erasure-code-profile';
+import { ErasureCodeProfileService } from './erasure-code-profile.service';
+
+describe('ErasureCodeProfileService', () => {
+ let service: ErasureCodeProfileService;
+ let httpTesting: HttpTestingController;
+ const apiPath = 'api/erasure_code_profile';
+ const testProfile: ErasureCodeProfile = { name: 'test', plugin: 'jerasure', k: 2, m: 1 };
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [ErasureCodeProfileService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(ErasureCodeProfileService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne(apiPath);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call create', () => {
+ service.create(testProfile).subscribe();
+ const req = httpTesting.expectOne(apiPath);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call delete', () => {
+ service.delete('test').subscribe();
+ const req = httpTesting.expectOne(`${apiPath}/test`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call getInfo', () => {
+ service.getInfo().subscribe();
+ const req = httpTesting.expectOne(`ui-${apiPath}/info`);
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts
new file mode 100644
index 000000000..d2bd131a4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts
@@ -0,0 +1,110 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+import { ErasureCodeProfile } from '../models/erasure-code-profile';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ErasureCodeProfileService {
+ apiPath = 'api/erasure_code_profile';
+
+ formTooltips = {
+ // Copied from /doc/rados/operations/erasure-code.*.rst
+ k: $localize`Each object is split in data-chunks parts, each stored on a different OSD.`,
+
+ m: $localize`Compute coding chunks for each object and store them on different OSDs.
+ The number of coding chunks is also the number of OSDs that can be down without losing data.`,
+
+ plugins: {
+ jerasure: {
+ description: $localize`The jerasure plugin is the most generic and flexible plugin,
+ it is also the default for Ceph erasure coded pools.`,
+ technique: $localize`The more flexible technique is reed_sol_van : it is enough to set k
+ and m. The cauchy_good technique can be faster but you need to chose the packetsize
+ carefully. All of reed_sol_r6_op, liberation, blaum_roth, liber8tion are RAID6 equivalents
+ in the sense that they can only be configured with m=2.`,
+ packetSize: $localize`The encoding will be done on packets of bytes size at a time.
+ Choosing the right packet size is difficult.
+ The jerasure documentation contains extensive information on this topic.`
+ },
+ lrc: {
+ description: $localize`With the jerasure plugin, when an erasure coded object is stored on
+ multiple OSDs, recovering from the loss of one OSD requires reading from all the others.
+ For instance if jerasure is configured with k=8 and m=4, losing one OSD requires reading
+ from the eleven others to repair.
+
+ The lrc erasure code plugin creates local parity chunks to be able to recover using
+ less OSDs. For instance if lrc is configured with k=8, m=4 and l=4, it will create
+ an additional parity chunk for every four OSDs. When a single OSD is lost, it can be
+ recovered with only four OSDs instead of eleven.`,
+ l: $localize`Group the coding and data chunks into sets of size locality. For instance,
+ for k=4 and m=2, when locality=3 two groups of three are created. Each set can
+ be recovered without reading chunks from another set.`,
+ crushLocality: $localize`The type of the crush bucket in which each set of chunks defined
+ by l will be stored. For instance, if it is set to rack, each group of l chunks will be
+ placed in a different rack. It is used to create a CRUSH rule step such as step choose
+ rack. If it is not set, no such grouping is done.`
+ },
+ isa: {
+ description: $localize`The isa plugin encapsulates the ISA library. It only runs on Intel processors.`,
+ technique: $localize`The ISA plugin comes in two Reed Solomon forms.
+ If reed_sol_van is set, it is Vandermonde, if cauchy is set, it is Cauchy.`
+ },
+ shec: {
+ description: $localize`The shec plugin encapsulates the multiple SHEC library.
+ It allows ceph to recover data more efficiently than Reed Solomon codes.`,
+ c: $localize`The number of parity chunks each of which includes each data chunk in its
+ calculation range. The number is used as a durability estimator. For instance, if c=2,
+ 2 OSDs can be down without losing data.`
+ },
+ clay: {
+ description: $localize`CLAY (short for coupled-layer) codes are erasure codes designed to
+ bring about significant savings in terms of network bandwidth and disk IO when a failed
+ node/OSD/rack is being repaired.`,
+ d: $localize`Number of OSDs requested to send data during recovery of a single chunk.
+ d needs to be chosen such that k+1 <= d <= k+m-1. The larger the d, the better
+ the savings.`,
+ scalar_mds: $localize`scalar_mds specifies the plugin that is used as a building block
+ in the layered construction. It can be one of jerasure, isa, shec.`,
+ technique: $localize`technique specifies the technique that will be picked
+ within the 'scalar_mds' plugin specified. Supported techniques
+ are 'reed_sol_van', 'reed_sol_r6_op', 'cauchy_orig',
+ 'cauchy_good', 'liber8tion' for jerasure, 'reed_sol_van',
+ 'cauchy' for isa and 'single', 'multiple' for shec.`
+ }
+ },
+
+ crushRoot: $localize`The name of the crush bucket used for the first step of the CRUSH rule.
+ For instance step take default.`,
+
+ crushFailureDomain: $localize`Ensure that no two chunks are in a bucket with the same failure
+ domain. For instance, if the failure domain is host no two chunks will be stored on the same
+ host. It is used to create a CRUSH rule step such as step chooseleaf host.`,
+
+ crushDeviceClass: $localize`Restrict placement to devices of a specific class
+ (e.g., ssd or hdd), using the crush device class names in the CRUSH map.`,
+
+ directory: $localize`Set the directory name from which the erasure code plugin is loaded.`
+ };
+
+ constructor(private http: HttpClient) {}
+
+ list(): Observable<ErasureCodeProfile[]> {
+ return this.http.get<ErasureCodeProfile[]>(this.apiPath);
+ }
+
+ create(ecp: ErasureCodeProfile) {
+ return this.http.post(this.apiPath, ecp, { observe: 'response' });
+ }
+
+ delete(name: string) {
+ return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' });
+ }
+
+ getInfo() {
+ return this.http.get(`ui-${this.apiPath}/info`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts
new file mode 100644
index 000000000..84eeac0f3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts
@@ -0,0 +1,40 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HealthService } from './health.service';
+
+describe('HealthService', () => {
+ let service: HealthService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [HealthService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(HealthService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getFullHealth', () => {
+ service.getFullHealth().subscribe();
+ const req = httpTesting.expectOne('api/health/full');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getMinimalHealth', () => {
+ service.getMinimalHealth().subscribe();
+ const req = httpTesting.expectOne('api/health/minimal');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts
new file mode 100644
index 000000000..a8f7c467a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts
@@ -0,0 +1,17 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class HealthService {
+ constructor(private http: HttpClient) {}
+
+ getFullHealth() {
+ return this.http.get('api/health/full');
+ }
+
+ getMinimalHealth() {
+ return this.http.get('api/health/minimal');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
new file mode 100644
index 000000000..e4b6476f2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
@@ -0,0 +1,91 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HostService } from './host.service';
+
+describe('HostService', () => {
+ let service: HostService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [HostService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(HostService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', fakeAsync(() => {
+ let result;
+ service.list('true').subscribe((resp) => (result = resp));
+ const req = httpTesting.expectOne('api/host?facts=true');
+ expect(req.request.method).toBe('GET');
+ req.flush(['foo', 'bar']);
+ tick();
+ expect(result).toEqual(['foo', 'bar']);
+ }));
+
+ it('should make a GET request on the devices endpoint when requesting devices', () => {
+ const hostname = 'hostname';
+ service.getDevices(hostname).subscribe();
+ const req = httpTesting.expectOne(`api/host/${hostname}/devices`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should update host', fakeAsync(() => {
+ service.update('mon0', true, ['foo', 'bar'], true, false).subscribe();
+ const req = httpTesting.expectOne('api/host/mon0');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({
+ force: false,
+ labels: ['foo', 'bar'],
+ maintenance: true,
+ update_labels: true,
+ drain: false
+ });
+ }));
+
+ it('should test host drain call', fakeAsync(() => {
+ service.update('host0', false, null, false, false, true).subscribe();
+ const req = httpTesting.expectOne('api/host/host0');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({
+ force: false,
+ labels: null,
+ maintenance: false,
+ update_labels: false,
+ drain: true
+ });
+ }));
+
+ it('should call getInventory', () => {
+ service.getInventory('host-0').subscribe();
+ let req = httpTesting.expectOne('api/host/host-0/inventory');
+ expect(req.request.method).toBe('GET');
+
+ service.getInventory('host-0', true).subscribe();
+ req = httpTesting.expectOne('api/host/host-0/inventory?refresh=true');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call inventoryList', () => {
+ service.inventoryList().subscribe();
+ let req = httpTesting.expectOne('ui-api/host/inventory');
+ expect(req.request.method).toBe('GET');
+
+ service.inventoryList(true).subscribe();
+ req = httpTesting.expectOne('ui-api/host/inventory?refresh=true');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
new file mode 100644
index 000000000..d13f41527
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
@@ -0,0 +1,154 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable, of as observableOf } from 'rxjs';
+import { map, mergeMap, toArray } from 'rxjs/operators';
+
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { InventoryHost } from '~/app/ceph/cluster/inventory/inventory-host.model';
+import { ApiClient } from '~/app/shared/api/api-client';
+import { CdHelperClass } from '~/app/shared/classes/cd-helper.class';
+import { Daemon } from '../models/daemon.interface';
+import { CdDevice } from '../models/devices';
+import { SmartDataResponseV1 } from '../models/smart';
+import { DeviceService } from '../services/device.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class HostService extends ApiClient {
+ baseURL = 'api/host';
+ baseUIURL = 'ui-api/host';
+
+ predefinedLabels = ['mon', 'mgr', 'osd', 'mds', 'rgw', 'nfs', 'iscsi', 'rbd', 'grafana'];
+
+ constructor(private http: HttpClient, private deviceService: DeviceService) {
+ super();
+ }
+
+ list(facts: string): Observable<object[]> {
+ return this.http.get<object[]>(this.baseURL, {
+ headers: { Accept: 'application/vnd.ceph.api.v1.1+json' },
+ params: { facts: facts }
+ });
+ }
+
+ create(hostname: string, addr: string, labels: string[], status: string) {
+ return this.http.post(
+ this.baseURL,
+ { hostname: hostname, addr: addr, labels: labels, status: status },
+ { observe: 'response', headers: { Accept: CdHelperClass.cdVersionHeader('0', '1') } }
+ );
+ }
+
+ delete(hostname: string) {
+ return this.http.delete(`${this.baseURL}/${hostname}`, { observe: 'response' });
+ }
+
+ getDevices(hostname: string): Observable<CdDevice[]> {
+ return this.http
+ .get<CdDevice[]>(`${this.baseURL}/${hostname}/devices`)
+ .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device))));
+ }
+
+ getSmartData(hostname: string) {
+ return this.http.get<SmartDataResponseV1>(`${this.baseURL}/${hostname}/smart`);
+ }
+
+ getDaemons(hostname: string): Observable<Daemon[]> {
+ return this.http.get<Daemon[]>(`${this.baseURL}/${hostname}/daemons`);
+ }
+
+ getLabels(): Observable<string[]> {
+ return this.http.get<string[]>(`${this.baseUIURL}/labels`);
+ }
+
+ update(
+ hostname: string,
+ updateLabels = false,
+ labels: string[] = [],
+ maintenance = false,
+ force = false,
+ drain = false
+ ) {
+ return this.http.put(
+ `${this.baseURL}/${hostname}`,
+ {
+ update_labels: updateLabels,
+ labels: labels,
+ maintenance: maintenance,
+ force: force,
+ drain: drain
+ },
+ { headers: { Accept: this.getVersionHeaderValue(0, 1) } }
+ );
+ }
+
+ identifyDevice(hostname: string, device: string, duration: number) {
+ return this.http.post(`${this.baseURL}/${hostname}/identify_device`, {
+ device,
+ duration
+ });
+ }
+
+ private getInventoryParams(refresh?: boolean): HttpParams {
+ let params = new HttpParams();
+ if (refresh) {
+ params = params.append('refresh', _.toString(refresh));
+ }
+ return params;
+ }
+
+ /**
+ * Get inventory of a host.
+ *
+ * @param hostname the host query.
+ * @param refresh true to ask the Orchestrator to refresh inventory.
+ */
+ getInventory(hostname: string, refresh?: boolean): Observable<InventoryHost> {
+ const params = this.getInventoryParams(refresh);
+ return this.http.get<InventoryHost>(`${this.baseURL}/${hostname}/inventory`, {
+ params: params
+ });
+ }
+
+ /**
+ * Get inventories of all hosts.
+ *
+ * @param refresh true to ask the Orchestrator to refresh inventory.
+ */
+ inventoryList(refresh?: boolean): Observable<InventoryHost[]> {
+ const params = this.getInventoryParams(refresh);
+ return this.http.get<InventoryHost[]>(`${this.baseUIURL}/inventory`, { params: params });
+ }
+
+ /**
+ * Get device list via host inventories.
+ *
+ * @param hostname the host to query. undefined for all hosts.
+ * @param refresh true to ask the Orchestrator to refresh inventory.
+ */
+ inventoryDeviceList(hostname?: string, refresh?: boolean): Observable<InventoryDevice[]> {
+ let observable;
+ if (hostname) {
+ observable = this.getInventory(hostname, refresh).pipe(toArray());
+ } else {
+ observable = this.inventoryList(refresh);
+ }
+ return observable.pipe(
+ mergeMap((hosts: InventoryHost[]) => {
+ const devices = _.flatMap(hosts, (host) => {
+ return host.devices.map((device) => {
+ device.hostname = host.name;
+ device.uid = device.device_id
+ ? `${device.device_id}-${device.hostname}-${device.path}`
+ : `${device.hostname}-${device.path}`;
+ return device;
+ });
+ });
+ return observableOf(devices);
+ })
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts
new file mode 100644
index 000000000..fcb1804a6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts
@@ -0,0 +1,97 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { IscsiService } from './iscsi.service';
+
+describe('IscsiService', () => {
+ let service: IscsiService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [IscsiService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(IscsiService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call listTargets', () => {
+ service.listTargets().subscribe();
+ const req = httpTesting.expectOne('api/iscsi/target');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getTarget', () => {
+ service.getTarget('iqn.foo').subscribe();
+ const req = httpTesting.expectOne('api/iscsi/target/iqn.foo');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call status', () => {
+ service.status().subscribe();
+ const req = httpTesting.expectOne('ui-api/iscsi/status');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call settings', () => {
+ service.settings().subscribe();
+ const req = httpTesting.expectOne('ui-api/iscsi/settings');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call portals', () => {
+ service.portals().subscribe();
+ const req = httpTesting.expectOne('ui-api/iscsi/portals');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call createTarget', () => {
+ service.createTarget({ target_iqn: 'foo' }).subscribe();
+ const req = httpTesting.expectOne('api/iscsi/target');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ target_iqn: 'foo' });
+ });
+
+ it('should call updateTarget', () => {
+ service.updateTarget('iqn.foo', { target_iqn: 'foo' }).subscribe();
+ const req = httpTesting.expectOne('api/iscsi/target/iqn.foo');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ target_iqn: 'foo' });
+ });
+
+ it('should call deleteTarget', () => {
+ service.deleteTarget('target_iqn').subscribe();
+ const req = httpTesting.expectOne('api/iscsi/target/target_iqn');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call getDiscovery', () => {
+ service.getDiscovery().subscribe();
+ const req = httpTesting.expectOne('api/iscsi/discoveryauth');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call updateDiscovery', () => {
+ service
+ .updateDiscovery({
+ user: 'foo',
+ password: 'bar',
+ mutual_user: 'mutual_foo',
+ mutual_password: 'mutual_bar'
+ })
+ .subscribe();
+ const req = httpTesting.expectOne('api/iscsi/discoveryauth');
+ expect(req.request.method).toBe('PUT');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts
new file mode 100644
index 000000000..9ef0310c7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts
@@ -0,0 +1,60 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { cdEncode } from '../decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class IscsiService {
+ constructor(private http: HttpClient) {}
+
+ listTargets() {
+ return this.http.get(`api/iscsi/target`);
+ }
+
+ getTarget(target_iqn: string) {
+ return this.http.get(`api/iscsi/target/${target_iqn}`);
+ }
+
+ updateTarget(target_iqn: string, target: any) {
+ return this.http.put(`api/iscsi/target/${target_iqn}`, target, { observe: 'response' });
+ }
+
+ status() {
+ return this.http.get(`ui-api/iscsi/status`);
+ }
+
+ settings() {
+ return this.http.get(`ui-api/iscsi/settings`);
+ }
+
+ version() {
+ return this.http.get(`ui-api/iscsi/version`);
+ }
+
+ portals() {
+ return this.http.get(`ui-api/iscsi/portals`);
+ }
+
+ createTarget(target: any) {
+ return this.http.post(`api/iscsi/target`, target, { observe: 'response' });
+ }
+
+ deleteTarget(target_iqn: string) {
+ return this.http.delete(`api/iscsi/target/${target_iqn}`, { observe: 'response' });
+ }
+
+ getDiscovery() {
+ return this.http.get(`api/iscsi/discoveryauth`);
+ }
+
+ updateDiscovery(auth: any) {
+ return this.http.put(`api/iscsi/discoveryauth`, auth);
+ }
+
+ overview() {
+ return this.http.get(`ui-api/iscsi/overview`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts
new file mode 100644
index 000000000..6458827f0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts
@@ -0,0 +1,39 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LoggingService } from './logging.service';
+
+describe('LoggingService', () => {
+ let service: LoggingService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [LoggingService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(LoggingService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call jsError', () => {
+ service.jsError('foo', 'bar', 'baz').subscribe();
+ const req = httpTesting.expectOne('ui-api/logging/js-error');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({
+ url: 'foo',
+ message: 'bar',
+ stack: 'baz'
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts
new file mode 100644
index 000000000..85846946b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts
@@ -0,0 +1,18 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LoggingService {
+ constructor(private http: HttpClient) {}
+
+ jsError(url: string, message: string, stack: any) {
+ const request = {
+ url: url,
+ message: message,
+ stack: stack
+ };
+ return this.http.post('ui-api/logging/js-error', request);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts
new file mode 100644
index 000000000..82c12dad8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LogsService } from './logs.service';
+
+describe('LogsService', () => {
+ let service: LogsService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [LogsService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(LogsService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getLogs', () => {
+ service.getLogs().subscribe();
+ const req = httpTesting.expectOne('api/logs/all');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts
new file mode 100644
index 000000000..252769dbd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts
@@ -0,0 +1,17 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LogsService {
+ constructor(private http: HttpClient) {}
+
+ getLogs() {
+ return this.http.get('api/logs/all');
+ }
+
+ validateDashboardUrl(uid: string) {
+ return this.http.get(`api/grafana/validation/${uid}`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts
new file mode 100644
index 000000000..77e6fb221
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts
@@ -0,0 +1,66 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrModuleService } from './mgr-module.service';
+
+describe('MgrModuleService', () => {
+ let service: MgrModuleService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [MgrModuleService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(MgrModuleService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/mgr/module');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getConfig', () => {
+ service.getConfig('foo').subscribe();
+ const req = httpTesting.expectOne('api/mgr/module/foo');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call updateConfig', () => {
+ const config = { foo: 'bar' };
+ service.updateConfig('xyz', config).subscribe();
+ const req = httpTesting.expectOne('api/mgr/module/xyz');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body.config).toEqual(config);
+ });
+
+ it('should call enable', () => {
+ service.enable('foo').subscribe();
+ const req = httpTesting.expectOne('api/mgr/module/foo/enable');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call disable', () => {
+ service.disable('bar').subscribe();
+ const req = httpTesting.expectOne('api/mgr/module/bar/disable');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call getOptions', () => {
+ service.getOptions('foo').subscribe();
+ const req = httpTesting.expectOne('api/mgr/module/foo/options');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts
new file mode 100644
index 000000000..3942a1a44
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts
@@ -0,0 +1,65 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class MgrModuleService {
+ private url = 'api/mgr/module';
+
+ constructor(private http: HttpClient) {}
+
+ /**
+ * Get the list of Ceph Mgr modules and their state (enabled/disabled).
+ * @return {Observable<Object[]>}
+ */
+ list(): Observable<Object[]> {
+ return this.http.get<Object[]>(`${this.url}`);
+ }
+
+ /**
+ * Get the Ceph Mgr module configuration.
+ * @param {string} module The name of the mgr module.
+ * @return {Observable<Object>}
+ */
+ getConfig(module: string): Observable<Object> {
+ return this.http.get(`${this.url}/${module}`);
+ }
+
+ /**
+ * Update the Ceph Mgr module configuration.
+ * @param {string} module The name of the mgr module.
+ * @param {object} config The configuration.
+ * @return {Observable<Object>}
+ */
+ updateConfig(module: string, config: object): Observable<Object> {
+ return this.http.put(`${this.url}/${module}`, { config: config });
+ }
+
+ /**
+ * Enable the Ceph Mgr module.
+ * @param {string} module The name of the mgr module.
+ */
+ enable(module: string) {
+ return this.http.post(`${this.url}/${module}/enable`, null);
+ }
+
+ /**
+ * Disable the Ceph Mgr module.
+ * @param {string} module The name of the mgr module.
+ */
+ disable(module: string) {
+ return this.http.post(`${this.url}/${module}/disable`, null);
+ }
+
+ /**
+ * Get the Ceph Mgr module options.
+ * @param {string} module The name of the mgr module.
+ * @return {Observable<Object>}
+ */
+ getOptions(module: string): Observable<Object> {
+ return this.http.get(`${this.url}/${module}/options`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts
new file mode 100644
index 000000000..29396866d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MonitorService } from './monitor.service';
+
+describe('MonitorService', () => {
+ let service: MonitorService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [MonitorService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(MonitorService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getMonitor', () => {
+ service.getMonitor().subscribe();
+ const req = httpTesting.expectOne('api/monitor');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts
new file mode 100644
index 000000000..42ca9a7af
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts
@@ -0,0 +1,13 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class MonitorService {
+ constructor(private http: HttpClient) {}
+
+ getMonitor() {
+ return this.http.get('api/monitor');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts
new file mode 100644
index 000000000..e186e8423
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { MotdService } from '~/app/shared/api/motd.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('MotdService', () => {
+ let service: MotdService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [MotdService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(MotdService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should get MOTD', () => {
+ service.get().subscribe();
+ const req = httpTesting.expectOne('ui-api/motd');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts
new file mode 100644
index 000000000..dd17b2e04
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts
@@ -0,0 +1,25 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+export interface Motd {
+ message: string;
+ md5: string;
+ severity: 'info' | 'warning' | 'danger';
+ // The expiration date in ISO 8601. Does not expire if empty.
+ expires: string;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class MotdService {
+ private url = 'ui-api/motd';
+
+ constructor(private http: HttpClient) {}
+
+ get(): Observable<Motd | null> {
+ return this.http.get<Motd | null>(this.url);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts
new file mode 100644
index 000000000..139fa490b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts
@@ -0,0 +1,74 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NfsService } from './nfs.service';
+
+describe('NfsService', () => {
+ let service: NfsService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [NfsService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(NfsService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/nfs-ganesha/export');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call get', () => {
+ service.get('cluster_id', 'export_id').subscribe();
+ const req = httpTesting.expectOne('api/nfs-ganesha/export/cluster_id/export_id');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call create', () => {
+ service.create('foo').subscribe();
+ const req = httpTesting.expectOne('api/nfs-ganesha/export');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual('foo');
+ });
+
+ it('should call update', () => {
+ service.update('cluster_id', 1, 'foo').subscribe();
+ const req = httpTesting.expectOne('api/nfs-ganesha/export/cluster_id/1');
+ expect(req.request.body).toEqual('foo');
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call delete', () => {
+ service.delete('hostName', 'exportId').subscribe();
+ const req = httpTesting.expectOne('api/nfs-ganesha/export/hostName/exportId');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call lsDir', () => {
+ service.lsDir('a', 'foo_dir').subscribe();
+ const req = httpTesting.expectOne('ui-api/nfs-ganesha/lsdir/a?root_dir=foo_dir');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should not call lsDir if volume is not provided', fakeAsync(() => {
+ service.lsDir('', 'foo_dir').subscribe({
+ error: (error: string) => expect(error).toEqual('Please specify a filesystem volume.')
+ });
+ tick();
+ httpTesting.expectNone('ui-api/nfs-ganesha/lsdir/?root_dir=foo_dir');
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts
new file mode 100644
index 000000000..9b4e4a0a2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts
@@ -0,0 +1,108 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable, throwError } from 'rxjs';
+
+import { NfsFSAbstractionLayer } from '~/app/ceph/nfs/models/nfs.fsal';
+import { ApiClient } from '~/app/shared/api/api-client';
+
+export interface Directory {
+ paths: string[];
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NfsService extends ApiClient {
+ apiPath = 'api/nfs-ganesha';
+ uiApiPath = 'ui-api/nfs-ganesha';
+
+ nfsAccessType = [
+ {
+ value: 'RW',
+ help: $localize`Allows all operations`
+ },
+ {
+ value: 'RO',
+ help: $localize`Allows only operations that do not modify the server`
+ },
+ {
+ value: 'NONE',
+ help: $localize`Allows no access at all`
+ }
+ ];
+
+ nfsFsal: NfsFSAbstractionLayer[] = [
+ {
+ value: 'CEPH',
+ descr: $localize`CephFS`,
+ disabled: false
+ },
+ {
+ value: 'RGW',
+ descr: $localize`Object Gateway`,
+ disabled: false
+ }
+ ];
+
+ nfsSquash = {
+ no_root_squash: ['no_root_squash', 'noidsquash', 'none'],
+ root_id_squash: ['root_id_squash', 'rootidsquash', 'rootid'],
+ root_squash: ['root_squash', 'rootsquash', 'root'],
+ all_squash: ['all_squash', 'allsquash', 'all', 'allanonymous', 'all_anonymous']
+ };
+
+ constructor(private http: HttpClient) {
+ super();
+ }
+
+ list() {
+ return this.http.get(`${this.apiPath}/export`);
+ }
+
+ get(clusterId: string, exportId: string) {
+ return this.http.get(`${this.apiPath}/export/${clusterId}/${exportId}`);
+ }
+
+ create(nfs: any) {
+ return this.http.post(`${this.apiPath}/export`, nfs, {
+ headers: { Accept: this.getVersionHeaderValue(2, 0) },
+ observe: 'response'
+ });
+ }
+
+ update(clusterId: string, id: number, nfs: any) {
+ return this.http.put(`${this.apiPath}/export/${clusterId}/${id}`, nfs, {
+ headers: { Accept: this.getVersionHeaderValue(2, 0) },
+ observe: 'response'
+ });
+ }
+
+ delete(clusterId: string, exportId: string) {
+ return this.http.delete(`${this.apiPath}/export/${clusterId}/${exportId}`, {
+ headers: { Accept: this.getVersionHeaderValue(2, 0) },
+ observe: 'response'
+ });
+ }
+
+ listClusters() {
+ return this.http.get(`${this.apiPath}/cluster`, {
+ headers: { Accept: this.getVersionHeaderValue(0, 1) }
+ });
+ }
+
+ lsDir(fs_name: string, root_dir: string): Observable<Directory> {
+ if (!fs_name) {
+ return throwError($localize`Please specify a filesystem volume.`);
+ }
+ return this.http.get<Directory>(`${this.uiApiPath}/lsdir/${fs_name}?root_dir=${root_dir}`);
+ }
+
+ fsals() {
+ return this.http.get(`${this.uiApiPath}/fsals`);
+ }
+
+ filesystems() {
+ return this.http.get(`${this.uiApiPath}/cephfs/filesystems`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts
new file mode 100644
index 000000000..c49cb8b0d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts
@@ -0,0 +1,35 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OrchestratorService } from './orchestrator.service';
+
+describe('OrchestratorService', () => {
+ let service: OrchestratorService;
+ let httpTesting: HttpTestingController;
+ const uiApiPath = 'ui-api/orchestrator';
+
+ configureTestBed({
+ providers: [OrchestratorService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(OrchestratorService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call status', () => {
+ service.status().subscribe();
+ const req = httpTesting.expectOne(`${uiApiPath}/status`);
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts
new file mode 100644
index 000000000..a6e33e834
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts
@@ -0,0 +1,46 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+
+import { OrchestratorFeature } from '../models/orchestrator.enum';
+import { OrchestratorStatus } from '../models/orchestrator.interface';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class OrchestratorService {
+ private url = 'ui-api/orchestrator';
+
+ disableMessages = {
+ noOrchestrator: $localize`The feature is disabled because Orchestrator is not available.`,
+ missingFeature: $localize`The Orchestrator backend doesn't support this feature.`
+ };
+
+ constructor(private http: HttpClient) {}
+
+ status(): Observable<OrchestratorStatus> {
+ return this.http.get<OrchestratorStatus>(`${this.url}/status`);
+ }
+
+ hasFeature(status: OrchestratorStatus, features: OrchestratorFeature[]): boolean {
+ return _.every(features, (feature) => _.get(status.features, `${feature}.available`));
+ }
+
+ getTableActionDisableDesc(
+ status: OrchestratorStatus,
+ features: OrchestratorFeature[]
+ ): boolean | string {
+ if (!status) {
+ return false;
+ }
+ if (!status.available) {
+ return this.disableMessages.noOrchestrator;
+ }
+ if (!this.hasFeature(status, features)) {
+ return this.disableMessages.missingFeature;
+ }
+ return false;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts
new file mode 100644
index 000000000..d1f999779
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts
@@ -0,0 +1,183 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdService } from './osd.service';
+
+describe('OsdService', () => {
+ let service: OsdService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [OsdService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(OsdService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call create', () => {
+ const trackingId = 'all_hdd, host1_ssd';
+ const post_data = {
+ method: 'drive_groups',
+ data: [
+ {
+ service_name: 'osd',
+ service_id: 'all_hdd',
+ host_pattern: '*',
+ data_devices: {
+ rotational: true
+ }
+ },
+ {
+ service_name: 'osd',
+ service_id: 'host1_ssd',
+ host_pattern: 'host1',
+ data_devices: {
+ rotational: false
+ }
+ }
+ ],
+ tracking_id: trackingId
+ };
+ service.create(post_data.data, trackingId).subscribe();
+ const req = httpTesting.expectOne('api/osd');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(post_data);
+ });
+
+ it('should call delete', () => {
+ const id = 1;
+ service.delete(id, true, true).subscribe();
+ const req = httpTesting.expectOne(`api/osd/${id}?preserve_id=true&force=true`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call getList', () => {
+ service.getList().subscribe();
+ const req = httpTesting.expectOne('api/osd');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getDetails', () => {
+ service.getDetails(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call scrub, with deep=true', () => {
+ service.scrub('foo', true).subscribe();
+ const req = httpTesting.expectOne('api/osd/foo/scrub?deep=true');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call scrub, with deep=false', () => {
+ service.scrub('foo', false).subscribe();
+ const req = httpTesting.expectOne('api/osd/foo/scrub?deep=false');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call getFlags', () => {
+ service.getFlags().subscribe();
+ const req = httpTesting.expectOne('api/osd/flags');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call updateFlags', () => {
+ service.updateFlags(['foo']).subscribe();
+ const req = httpTesting.expectOne('api/osd/flags');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ flags: ['foo'] });
+ });
+
+ it('should call updateIndividualFlags to update individual flags', () => {
+ const flags = { noin: true, noout: true };
+ const ids = [0, 1];
+ service.updateIndividualFlags(flags, ids).subscribe();
+ const req = httpTesting.expectOne('api/osd/flags/individual');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ flags: flags, ids: ids });
+ });
+
+ it('should mark the OSD out', () => {
+ service.markOut(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/mark');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ action: 'out' });
+ });
+
+ it('should mark the OSD in', () => {
+ service.markIn(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/mark');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ action: 'in' });
+ });
+
+ it('should mark the OSD down', () => {
+ service.markDown(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/mark');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ action: 'down' });
+ });
+
+ it('should reweight an OSD', () => {
+ service.reweight(1, 0.5).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/reweight');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ weight: 0.5 });
+ });
+
+ it('should update OSD', () => {
+ service.update(1, 'hdd').subscribe();
+ const req = httpTesting.expectOne('api/osd/1');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ device_class: 'hdd' });
+ });
+
+ it('should mark an OSD lost', () => {
+ service.markLost(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/mark');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ action: 'lost' });
+ });
+
+ it('should purge an OSD', () => {
+ service.purge(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/purge');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should destroy an OSD', () => {
+ service.destroy(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/destroy');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should return if it is safe to destroy an OSD', () => {
+ service.safeToDestroy('[0,1]').subscribe();
+ const req = httpTesting.expectOne('api/osd/safe_to_destroy?ids=[0,1]');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call the devices endpoint to retrieve smart data', () => {
+ service.getDevices(1).subscribe();
+ const req = httpTesting.expectOne('api/osd/1/devices');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getDeploymentOptions', () => {
+ service.getDeploymentOptions().subscribe();
+ const req = httpTesting.expectOne('ui-api/osd/deployment_options');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts
new file mode 100644
index 000000000..34461bf63
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts
@@ -0,0 +1,190 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { CdDevice } from '../models/devices';
+import { InventoryDeviceType } from '../models/inventory-device-type.model';
+import { DeploymentOptions } from '../models/osd-deployment-options';
+import { OsdSettings } from '../models/osd-settings';
+import { SmartDataResponseV1 } from '../models/smart';
+import { DeviceService } from '../services/device.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class OsdService {
+ private path = 'api/osd';
+ private uiPath = 'ui-api/osd';
+
+ osdDevices: InventoryDeviceType[] = [];
+
+ osdRecvSpeedModalPriorities = {
+ KNOWN_PRIORITIES: [
+ {
+ name: null,
+ text: $localize`-- Select the priority --`,
+ values: {
+ osd_max_backfills: null,
+ osd_recovery_max_active: null,
+ osd_recovery_max_single_start: null,
+ osd_recovery_sleep: null
+ }
+ },
+ {
+ name: 'low',
+ text: $localize`Low`,
+ values: {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 1,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0.5
+ }
+ },
+ {
+ name: 'default',
+ text: $localize`Default`,
+ values: {
+ osd_max_backfills: 1,
+ osd_recovery_max_active: 3,
+ osd_recovery_max_single_start: 1,
+ osd_recovery_sleep: 0
+ }
+ },
+ {
+ name: 'high',
+ text: $localize`High`,
+ values: {
+ osd_max_backfills: 4,
+ osd_recovery_max_active: 4,
+ osd_recovery_max_single_start: 4,
+ osd_recovery_sleep: 0
+ }
+ }
+ ]
+ };
+
+ constructor(private http: HttpClient, private deviceService: DeviceService) {}
+
+ create(driveGroups: Object[], trackingId: string, method = 'drive_groups') {
+ const request = {
+ method: method,
+ data: driveGroups,
+ tracking_id: trackingId
+ };
+ return this.http.post(this.path, request, { observe: 'response' });
+ }
+
+ getList() {
+ return this.http.get(`${this.path}`);
+ }
+
+ getOsdSettings(): Observable<OsdSettings> {
+ return this.http.get<OsdSettings>(`${this.path}/settings`, {
+ headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }
+ });
+ }
+
+ getDetails(id: number) {
+ interface OsdData {
+ osd_map: { [key: string]: any };
+ osd_metadata: { [key: string]: any };
+ smart: { [device_identifier: string]: any };
+ }
+ return this.http.get<OsdData>(`${this.path}/${id}`);
+ }
+
+ /**
+ * @param id OSD ID
+ */
+ getSmartData(id: number) {
+ return this.http.get<SmartDataResponseV1>(`${this.path}/${id}/smart`);
+ }
+
+ scrub(id: string, deep: boolean) {
+ return this.http.post(`${this.path}/${id}/scrub?deep=${deep}`, null);
+ }
+
+ getDeploymentOptions() {
+ return this.http.get<DeploymentOptions>(`${this.uiPath}/deployment_options`);
+ }
+
+ getFlags() {
+ return this.http.get(`${this.path}/flags`);
+ }
+
+ updateFlags(flags: string[]) {
+ return this.http.put(`${this.path}/flags`, { flags: flags });
+ }
+
+ updateIndividualFlags(flags: { [flag: string]: boolean }, ids: number[]) {
+ return this.http.put(`${this.path}/flags/individual`, { flags: flags, ids: ids });
+ }
+
+ markOut(id: number) {
+ return this.http.put(`${this.path}/${id}/mark`, { action: 'out' });
+ }
+
+ markIn(id: number) {
+ return this.http.put(`${this.path}/${id}/mark`, { action: 'in' });
+ }
+
+ markDown(id: number) {
+ return this.http.put(`${this.path}/${id}/mark`, { action: 'down' });
+ }
+
+ reweight(id: number, weight: number) {
+ return this.http.post(`${this.path}/${id}/reweight`, { weight: weight });
+ }
+
+ update(id: number, deviceClass: string) {
+ return this.http.put(`${this.path}/${id}`, { device_class: deviceClass });
+ }
+
+ markLost(id: number) {
+ return this.http.put(`${this.path}/${id}/mark`, { action: 'lost' });
+ }
+
+ purge(id: number) {
+ return this.http.post(`${this.path}/${id}/purge`, null);
+ }
+
+ destroy(id: number) {
+ return this.http.post(`${this.path}/${id}/destroy`, null);
+ }
+
+ delete(id: number, preserveId?: boolean, force?: boolean) {
+ const params = {
+ preserve_id: preserveId ? 'true' : 'false',
+ force: force ? 'true' : 'false'
+ };
+ return this.http.delete(`${this.path}/${id}`, { observe: 'response', params: params });
+ }
+
+ safeToDestroy(ids: string) {
+ interface SafeToDestroyResponse {
+ active: number[];
+ missing_stats: number[];
+ stored_pgs: number[];
+ is_safe_to_destroy: boolean;
+ message?: string;
+ }
+ return this.http.get<SafeToDestroyResponse>(`${this.path}/safe_to_destroy?ids=${ids}`);
+ }
+
+ safeToDelete(ids: string) {
+ interface SafeToDeleteResponse {
+ is_safe_to_delete: boolean;
+ message?: string;
+ }
+ return this.http.get<SafeToDeleteResponse>(`${this.path}/safe_to_delete?svc_ids=${ids}`);
+ }
+
+ getDevices(osdId: number) {
+ return this.http
+ .get<CdDevice[]>(`${this.path}/${osdId}/devices`)
+ .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device))));
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts
new file mode 100644
index 000000000..12b13787b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts
@@ -0,0 +1,45 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { PerformanceCounterService } from './performance-counter.service';
+
+describe('PerformanceCounterService', () => {
+ let service: PerformanceCounterService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [PerformanceCounterService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(PerformanceCounterService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/perf_counters');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call get', () => {
+ let result;
+ service.get('foo', '1').subscribe((resp) => {
+ result = resp;
+ });
+ const req = httpTesting.expectOne('api/perf_counters/foo/1');
+ expect(req.request.method).toBe('GET');
+ req.flush({ counters: [{ foo: 'bar' }] });
+ expect(result).toEqual([{ foo: 'bar' }]);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts
new file mode 100644
index 000000000..36be6f383
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts
@@ -0,0 +1,29 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { of as observableOf } from 'rxjs';
+import { mergeMap } from 'rxjs/operators';
+
+import { cdEncode } from '../decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class PerformanceCounterService {
+ private url = 'api/perf_counters';
+
+ constructor(private http: HttpClient) {}
+
+ list() {
+ return this.http.get(this.url);
+ }
+
+ get(service_type: string, service_id: string) {
+ return this.http.get(`${this.url}/${service_type}/${service_id}`).pipe(
+ mergeMap((resp: any) => {
+ return observableOf(resp['counters']);
+ })
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts
new file mode 100644
index 000000000..292da3c21
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts
@@ -0,0 +1,123 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdConfigurationSourceField } from '../models/configuration';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
+import { PoolService } from './pool.service';
+
+describe('PoolService', () => {
+ let service: PoolService;
+ let httpTesting: HttpTestingController;
+ const apiPath = 'api/pool';
+
+ configureTestBed({
+ providers: [PoolService, RbdConfigurationService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(PoolService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getList', () => {
+ service.getList().subscribe();
+ const req = httpTesting.expectOne(`${apiPath}?stats=true`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getInfo', () => {
+ service.getInfo().subscribe();
+ const req = httpTesting.expectOne(`ui-${apiPath}/info`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call create', () => {
+ const pool = { pool: 'somePool' };
+ service.create(pool).subscribe();
+ const req = httpTesting.expectOne(apiPath);
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(pool);
+ });
+
+ it('should call update', () => {
+ service.update({ pool: 'somePool', application_metadata: [] }).subscribe();
+ const req = httpTesting.expectOne(`${apiPath}/somePool`);
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ application_metadata: [] });
+ });
+
+ it('should call delete', () => {
+ service.delete('somePool').subscribe();
+ const req = httpTesting.expectOne(`${apiPath}/somePool`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call list without parameter', fakeAsync(() => {
+ let result;
+ service.list().then((resp) => (result = resp));
+ const req = httpTesting.expectOne(`${apiPath}?attrs=`);
+ expect(req.request.method).toBe('GET');
+ req.flush(['foo', 'bar']);
+ tick();
+ expect(result).toEqual(['foo', 'bar']);
+ }));
+
+ it('should call list with a list', fakeAsync(() => {
+ let result;
+ service.list(['foo']).then((resp) => (result = resp));
+ const req = httpTesting.expectOne(`${apiPath}?attrs=foo`);
+ expect(req.request.method).toBe('GET');
+ req.flush(['foo', 'bar']);
+ tick();
+ expect(result).toEqual(['foo', 'bar']);
+ }));
+
+ it('should test injection of data from getConfiguration()', fakeAsync(() => {
+ const pool = 'foo';
+ let value;
+ service.getConfiguration(pool).subscribe((next) => (value = next));
+ const req = httpTesting.expectOne(`${apiPath}/${pool}/configuration`);
+ expect(req.request.method).toBe('GET');
+ req.flush([
+ {
+ name: 'rbd_qos_bps_limit',
+ value: '60',
+ source: RbdConfigurationSourceField.global
+ },
+ {
+ name: 'rbd_qos_iops_limit',
+ value: '0',
+ source: RbdConfigurationSourceField.global
+ }
+ ]);
+ tick();
+ expect(value).toEqual([
+ {
+ description: 'The desired limit of IO bytes per second.',
+ displayName: 'BPS Limit',
+ name: 'rbd_qos_bps_limit',
+ source: RbdConfigurationSourceField.global,
+ type: 0,
+ value: '60'
+ },
+ {
+ description: 'The desired limit of IO operations per second.',
+ displayName: 'IOPS Limit',
+ name: 'rbd_qos_iops_limit',
+ source: RbdConfigurationSourceField.global,
+ type: 1,
+ value: '0'
+ }
+ ]);
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts
new file mode 100644
index 000000000..78d5819ec
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts
@@ -0,0 +1,74 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { cdEncode } from '../decorators/cd-encode';
+import { RbdConfigurationEntry } from '../models/configuration';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class PoolService {
+ apiPath = 'api/pool';
+
+ constructor(private http: HttpClient, private rbdConfigurationService: RbdConfigurationService) {}
+
+ create(pool: any) {
+ return this.http.post(this.apiPath, pool, { observe: 'response' });
+ }
+
+ update(pool: any) {
+ let name: string;
+ if (pool.hasOwnProperty('srcpool')) {
+ name = pool.srcpool;
+ delete pool.srcpool;
+ } else {
+ name = pool.pool;
+ delete pool.pool;
+ }
+ return this.http.put(`${this.apiPath}/${encodeURIComponent(name)}`, pool, {
+ observe: 'response'
+ });
+ }
+
+ delete(name: string) {
+ return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' });
+ }
+
+ get(poolName: string) {
+ return this.http.get(`${this.apiPath}/${poolName}`);
+ }
+
+ getList() {
+ return this.http.get(`${this.apiPath}?stats=true`);
+ }
+
+ getConfiguration(poolName: string): Observable<RbdConfigurationEntry[]> {
+ return this.http.get<RbdConfigurationEntry[]>(`${this.apiPath}/${poolName}/configuration`).pipe(
+ // Add static data maintained in RbdConfigurationService
+ map((values) =>
+ values.map((entry) =>
+ Object.assign(entry, this.rbdConfigurationService.getOptionByName(entry.name))
+ )
+ )
+ );
+ }
+
+ getInfo() {
+ return this.http.get(`ui-${this.apiPath}/info`);
+ }
+
+ list(attrs: string[] = []) {
+ const attrsStr = attrs.join(',');
+ return this.http
+ .get(`${this.apiPath}?attrs=${attrsStr}`)
+ .toPromise()
+ .then((resp: any) => {
+ return resp;
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts
new file mode 100644
index 000000000..c42f6e7ac
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts
@@ -0,0 +1,247 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AlertmanagerNotification } from '../models/prometheus-alerts';
+import { PrometheusService } from './prometheus.service';
+import { SettingsService } from './settings.service';
+
+describe('PrometheusService', () => {
+ let service: PrometheusService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [PrometheusService, SettingsService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(PrometheusService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should get alerts', () => {
+ service.getAlerts().subscribe();
+ const req = httpTesting.expectOne('api/prometheus');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should get silences', () => {
+ service.getSilences().subscribe();
+ const req = httpTesting.expectOne('api/prometheus/silences');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should set a silence', () => {
+ const silence = {
+ id: 'someId',
+ matchers: [
+ {
+ name: 'getZero',
+ value: 0,
+ isRegex: false
+ }
+ ],
+ startsAt: '2019-01-25T14:32:46.646300974Z',
+ endsAt: '2019-01-25T18:32:46.646300974Z',
+ createdBy: 'someCreator',
+ comment: 'for testing purpose'
+ };
+ service.setSilence(silence).subscribe();
+ const req = httpTesting.expectOne('api/prometheus/silence');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(silence);
+ });
+
+ it('should expire a silence', () => {
+ service.expireSilence('someId').subscribe();
+ const req = httpTesting.expectOne('api/prometheus/silence/someId');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call getNotificationSince without a notification', () => {
+ service.getNotifications().subscribe();
+ const req = httpTesting.expectOne('api/prometheus/notifications?from=last');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getNotificationSince with notification', () => {
+ service.getNotifications({ id: '42' } as AlertmanagerNotification).subscribe();
+ const req = httpTesting.expectOne('api/prometheus/notifications?from=42');
+ expect(req.request.method).toBe('GET');
+ });
+
+ describe('test getRules()', () => {
+ let data: {}; // Subset of PrometheusRuleGroup to keep the tests concise.
+
+ beforeEach(() => {
+ data = {
+ groups: [
+ {
+ name: 'test',
+ rules: [
+ {
+ name: 'load_0',
+ type: 'alerting'
+ },
+ {
+ name: 'load_1',
+ type: 'alerting'
+ },
+ {
+ name: 'load_2',
+ type: 'alerting'
+ }
+ ]
+ },
+ {
+ name: 'recording_rule',
+ rules: [
+ {
+ name: 'node_memory_MemUsed_percent',
+ type: 'recording'
+ }
+ ]
+ }
+ ]
+ };
+ });
+
+ it('should get rules without applying filters', () => {
+ service.getRules().subscribe((rules) => {
+ expect(rules).toEqual(data);
+ });
+
+ const req = httpTesting.expectOne('api/prometheus/rules');
+ expect(req.request.method).toBe('GET');
+ req.flush(data);
+ });
+
+ it('should get rewrite rules only', () => {
+ service.getRules('rewrites').subscribe((rules) => {
+ expect(rules).toEqual({
+ groups: [
+ { name: 'test', rules: [] },
+ { name: 'recording_rule', rules: [] }
+ ]
+ });
+ });
+
+ const req = httpTesting.expectOne('api/prometheus/rules');
+ expect(req.request.method).toBe('GET');
+ req.flush(data);
+ });
+
+ it('should get alerting rules only', () => {
+ service.getRules('alerting').subscribe((rules) => {
+ expect(rules).toEqual({
+ groups: [
+ {
+ name: 'test',
+ rules: [
+ { name: 'load_0', type: 'alerting' },
+ { name: 'load_1', type: 'alerting' },
+ { name: 'load_2', type: 'alerting' }
+ ]
+ },
+ { name: 'recording_rule', rules: [] }
+ ]
+ });
+ });
+
+ const req = httpTesting.expectOne('api/prometheus/rules');
+ expect(req.request.method).toBe('GET');
+ req.flush(data);
+ });
+ });
+
+ describe('ifAlertmanagerConfigured', () => {
+ let x: any;
+ let host: string;
+
+ const receiveConfig = () => {
+ const req = httpTesting.expectOne('api/settings/alertmanager-api-host');
+ expect(req.request.method).toBe('GET');
+ req.flush({ value: host });
+ };
+
+ beforeEach(() => {
+ x = false;
+ TestBed.inject(SettingsService)['settings'] = {};
+ service.ifAlertmanagerConfigured(
+ (v) => (x = v),
+ () => (x = [])
+ );
+ host = 'http://localhost:9093';
+ });
+
+ it('changes x in a valid case', () => {
+ expect(x).toBe(false);
+ receiveConfig();
+ expect(x).toBe(host);
+ });
+
+ it('does changes x an empty array in a invalid case', () => {
+ host = '';
+ receiveConfig();
+ expect(x).toEqual([]);
+ });
+
+ it('disables the set setting', () => {
+ receiveConfig();
+ service.disableAlertmanagerConfig();
+ x = false;
+ service.ifAlertmanagerConfigured((v) => (x = v));
+ expect(x).toBe(false);
+ });
+ });
+
+ describe('ifPrometheusConfigured', () => {
+ let x: any;
+ let host: string;
+
+ const receiveConfig = () => {
+ const req = httpTesting.expectOne('api/settings/prometheus-api-host');
+ expect(req.request.method).toBe('GET');
+ req.flush({ value: host });
+ };
+
+ beforeEach(() => {
+ x = false;
+ TestBed.inject(SettingsService)['settings'] = {};
+ service.ifPrometheusConfigured(
+ (v) => (x = v),
+ () => (x = [])
+ );
+ host = 'http://localhost:9090';
+ });
+
+ it('changes x in a valid case', () => {
+ expect(x).toBe(false);
+ receiveConfig();
+ expect(x).toBe(host);
+ });
+
+ it('does changes x an empty array in a invalid case', () => {
+ host = '';
+ receiveConfig();
+ expect(x).toEqual([]);
+ });
+
+ it('disables the set setting', () => {
+ receiveConfig();
+ service.disablePrometheusConfig();
+ x = false;
+ service.ifPrometheusConfigured((v) => (x = v));
+ expect(x).toBe(false);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts
new file mode 100644
index 000000000..581917219
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts
@@ -0,0 +1,82 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { AlertmanagerSilence } from '../models/alertmanager-silence';
+import {
+ AlertmanagerAlert,
+ AlertmanagerNotification,
+ PrometheusRuleGroup
+} from '../models/prometheus-alerts';
+import { SettingsService } from './settings.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PrometheusService {
+ private baseURL = 'api/prometheus';
+ private settingsKey = {
+ alertmanager: 'api/settings/alertmanager-api-host',
+ prometheus: 'api/settings/prometheus-api-host'
+ };
+
+ constructor(private http: HttpClient, private settingsService: SettingsService) {}
+
+ ifAlertmanagerConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
+ this.settingsService.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn);
+ }
+
+ disableAlertmanagerConfig(): void {
+ this.settingsService.disableSetting(this.settingsKey.alertmanager);
+ }
+
+ ifPrometheusConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
+ this.settingsService.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn);
+ }
+
+ disablePrometheusConfig(): void {
+ this.settingsService.disableSetting(this.settingsKey.prometheus);
+ }
+
+ getAlerts(params = {}): Observable<AlertmanagerAlert[]> {
+ return this.http.get<AlertmanagerAlert[]>(this.baseURL, { params });
+ }
+
+ getSilences(params = {}): Observable<AlertmanagerSilence[]> {
+ return this.http.get<AlertmanagerSilence[]>(`${this.baseURL}/silences`, { params });
+ }
+
+ getRules(
+ type: 'all' | 'alerting' | 'rewrites' = 'all'
+ ): Observable<{ groups: PrometheusRuleGroup[] }> {
+ return this.http.get<{ groups: PrometheusRuleGroup[] }>(`${this.baseURL}/rules`).pipe(
+ map((rules) => {
+ if (['alerting', 'rewrites'].includes(type)) {
+ rules.groups.map((group) => {
+ group.rules = group.rules.filter((rule) => rule.type === type);
+ });
+ }
+ return rules;
+ })
+ );
+ }
+
+ setSilence(silence: AlertmanagerSilence) {
+ return this.http.post<object>(`${this.baseURL}/silence`, silence, { observe: 'response' });
+ }
+
+ expireSilence(silenceId: string) {
+ return this.http.delete(`${this.baseURL}/silence/${silenceId}`, { observe: 'response' });
+ }
+
+ getNotifications(
+ notification?: AlertmanagerNotification
+ ): Observable<AlertmanagerNotification[]> {
+ const url = `${this.baseURL}/notifications?from=${
+ notification && notification.id ? notification.id : 'last'
+ }`;
+ return this.http.get<AlertmanagerNotification[]>(url);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts
new file mode 100644
index 000000000..3f883d91f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts
@@ -0,0 +1,164 @@
+import { HttpRequest } from '@angular/common/http';
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+ TestRequest
+} from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdMirroringService } from './rbd-mirroring.service';
+
+describe('RbdMirroringService', () => {
+ let service: RbdMirroringService;
+ let httpTesting: HttpTestingController;
+ let getMirroringSummaryCalls: () => TestRequest[];
+ let flushCalls: (call: TestRequest) => void;
+
+ const summary: Record<string, any> = {
+ status: 0,
+ content_data: {
+ daemons: [],
+ pools: [],
+ image_error: [],
+ image_syncing: [],
+ image_ready: []
+ },
+ executing_tasks: [{}]
+ };
+
+ configureTestBed({
+ providers: [RbdMirroringService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RbdMirroringService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ getMirroringSummaryCalls = () => {
+ return httpTesting.match((request: HttpRequest<any>) => {
+ return request.url.match(/api\/block\/mirroring\/summary/) && request.method === 'GET';
+ });
+ };
+ flushCalls = (call: TestRequest) => {
+ if (!call.cancelled) {
+ call.flush(summary);
+ }
+ };
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should periodically poll summary', fakeAsync(() => {
+ const subs = service.startPolling();
+ tick();
+ const calledWith: any[] = [];
+ service.subscribeSummary((data) => {
+ calledWith.push(data);
+ });
+ tick(service.REFRESH_INTERVAL * 2);
+ const calls = getMirroringSummaryCalls();
+
+ expect(calls.length).toEqual(3);
+ calls.forEach((call: TestRequest) => flushCalls(call));
+ expect(calledWith).toEqual([summary]);
+
+ subs.unsubscribe();
+ }));
+
+ it('should get pool config', () => {
+ service.getPool('poolName').subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should update pool config', () => {
+ const request = {
+ mirror_mode: 'pool'
+ };
+ service.updatePool('poolName', request).subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(request);
+ });
+
+ it('should get site name', () => {
+ service.getSiteName().subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/site_name');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should set site name', () => {
+ service.setSiteName('site-a').subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/site_name');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ site_name: 'site-a' });
+ });
+
+ it('should create bootstrap token', () => {
+ service.createBootstrapToken('poolName').subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/bootstrap/token');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should import bootstrap token', () => {
+ service.importBootstrapToken('poolName', 'rx', 'token-1234').subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/bootstrap/peer');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({
+ direction: 'rx',
+ token: 'token-1234'
+ });
+ });
+
+ it('should get peer config', () => {
+ service.getPeer('poolName', 'peerUUID').subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer/peerUUID');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should add peer config', () => {
+ const request = {
+ cluster_name: 'remote',
+ client_id: 'admin',
+ mon_host: 'localhost',
+ key: '1234'
+ };
+ service.addPeer('poolName', request).subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(request);
+ });
+
+ it('should update peer config', () => {
+ const request = {
+ cluster_name: 'remote'
+ };
+ service.updatePeer('poolName', 'peerUUID', request).subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer/peerUUID');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(request);
+ });
+
+ it('should delete peer config', () => {
+ service.deletePeer('poolName', 'peerUUID').subscribe();
+
+ const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer/peerUUID');
+ expect(req.request.method).toBe('DELETE');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts
new file mode 100644
index 000000000..4958382e2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts
@@ -0,0 +1,114 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
+
+import { cdEncode, cdEncodeNot } from '../decorators/cd-encode';
+import { MirroringSummary } from '../models/mirroring-summary';
+import { TimerService } from '../services/timer.service';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class RbdMirroringService {
+ readonly REFRESH_INTERVAL = 30000;
+ // Observable sources
+ private summaryDataSource = new BehaviorSubject<MirroringSummary>(null);
+ // Observable streams
+ summaryData$ = this.summaryDataSource.asObservable();
+
+ constructor(private http: HttpClient, private timerService: TimerService) {}
+
+ startPolling(): Subscription {
+ return this.timerService
+ .get(() => this.retrieveSummaryObservable(), this.REFRESH_INTERVAL)
+ .subscribe(this.retrieveSummaryObserver());
+ }
+
+ refresh(): Subscription {
+ return this.retrieveSummaryObservable().subscribe(this.retrieveSummaryObserver());
+ }
+
+ private retrieveSummaryObservable(): Observable<MirroringSummary> {
+ return this.http.get('api/block/mirroring/summary');
+ }
+
+ private retrieveSummaryObserver(): (data: MirroringSummary) => void {
+ return (data: any) => {
+ this.summaryDataSource.next(data);
+ };
+ }
+
+ /**
+ * Subscribes to the summaryData,
+ * which is updated periodically or when a new task is created.
+ */
+ subscribeSummary(
+ next: (summary: MirroringSummary) => void,
+ error?: (error: any) => void
+ ): Subscription {
+ return this.summaryData$.pipe(filter((value) => !!value)).subscribe(next, error);
+ }
+
+ getPool(poolName: string) {
+ return this.http.get(`api/block/mirroring/pool/${poolName}`);
+ }
+
+ updatePool(poolName: string, request: any) {
+ return this.http.put(`api/block/mirroring/pool/${poolName}`, request, { observe: 'response' });
+ }
+
+ getSiteName() {
+ return this.http.get(`api/block/mirroring/site_name`);
+ }
+
+ setSiteName(@cdEncodeNot siteName: string) {
+ return this.http.put(
+ `api/block/mirroring/site_name`,
+ { site_name: siteName },
+ { observe: 'response' }
+ );
+ }
+
+ createBootstrapToken(poolName: string) {
+ return this.http.post(`api/block/mirroring/pool/${poolName}/bootstrap/token`, {});
+ }
+
+ importBootstrapToken(
+ poolName: string,
+ @cdEncodeNot direction: string,
+ @cdEncodeNot token: string
+ ) {
+ const request = {
+ direction: direction,
+ token: token
+ };
+ return this.http.post(`api/block/mirroring/pool/${poolName}/bootstrap/peer`, request, {
+ observe: 'response'
+ });
+ }
+
+ getPeer(poolName: string, peerUUID: string) {
+ return this.http.get(`api/block/mirroring/pool/${poolName}/peer/${peerUUID}`);
+ }
+
+ addPeer(poolName: string, request: any) {
+ return this.http.post(`api/block/mirroring/pool/${poolName}/peer`, request, {
+ observe: 'response'
+ });
+ }
+
+ updatePeer(poolName: string, peerUUID: string, request: any) {
+ return this.http.put(`api/block/mirroring/pool/${poolName}/peer/${peerUUID}`, request, {
+ observe: 'response'
+ });
+ }
+
+ deletePeer(poolName: string, peerUUID: string) {
+ return this.http.delete(`api/block/mirroring/pool/${poolName}/peer/${peerUUID}`, {
+ observe: 'response'
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts
new file mode 100644
index 000000000..d14b2bc40
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts
@@ -0,0 +1,30 @@
+import { RbdConfigurationEntry } from '../models/configuration';
+
+export interface RbdPool {
+ pool_name: string;
+ status: number;
+ value: RbdImage[];
+ headers: any;
+}
+
+export interface RbdImage {
+ disk_usage: number;
+ stripe_unit: number;
+ name: string;
+ parent: any;
+ pool_name: string;
+ num_objs: number;
+ block_name_prefix: string;
+ snapshots: any[];
+ obj_size: number;
+ data_pool: string;
+ total_disk_usage: number;
+ features: number;
+ configuration: RbdConfigurationEntry[];
+ timestamp: string;
+ id: string;
+ features_name: string[];
+ stripe_count: number;
+ order: number;
+ size: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
new file mode 100644
index 000000000..84abf6d34
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
@@ -0,0 +1,181 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ImageSpec } from '../models/image-spec';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
+import { RbdService } from './rbd.service';
+
+describe('RbdService', () => {
+ let service: RbdService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [RbdService, RbdConfigurationService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RbdService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call create', () => {
+ service.create('foo').subscribe();
+ const req = httpTesting.expectOne('api/block/image');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual('foo');
+ });
+
+ it('should call delete', () => {
+ service.delete(new ImageSpec('poolName', null, 'rbdName')).subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call update', () => {
+ service.update(new ImageSpec('poolName', null, 'rbdName'), 'foo').subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName');
+ expect(req.request.body).toEqual('foo');
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call get', () => {
+ service.get(new ImageSpec('poolName', null, 'rbdName')).subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call list', () => {
+ /* tslint:disable:no-empty */
+ const context = new CdTableFetchDataContext(() => {});
+ service.list(context.toParams()).subscribe();
+ const req = httpTesting.expectOne('api/block/image?offset=0&limit=10&search=&sort=+name');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call copy', () => {
+ service.copy(new ImageSpec('poolName', null, 'rbdName'), 'foo').subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/copy');
+ expect(req.request.body).toEqual('foo');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call flatten', () => {
+ service.flatten(new ImageSpec('poolName', null, 'rbdName')).subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/flatten');
+ expect(req.request.body).toEqual(null);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call defaultFeatures', () => {
+ service.defaultFeatures().subscribe();
+ const req = httpTesting.expectOne('api/block/image/default_features');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call cloneFormatVersion', () => {
+ service.cloneFormatVersion().subscribe();
+ const req = httpTesting.expectOne('api/block/image/clone_format_version');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call createSnapshot', () => {
+ service.createSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName').subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap');
+ expect(req.request.body).toEqual({
+ snapshot_name: 'snapshotName'
+ });
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call renameSnapshot', () => {
+ service
+ .renameSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName', 'foo')
+ .subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName');
+ expect(req.request.body).toEqual({
+ new_snap_name: 'foo'
+ });
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call protectSnapshot', () => {
+ service
+ .protectSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName', true)
+ .subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName');
+ expect(req.request.body).toEqual({
+ is_protected: true
+ });
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call rollbackSnapshot', () => {
+ service
+ .rollbackSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName')
+ .subscribe();
+ const req = httpTesting.expectOne(
+ 'api/block/image/poolName%2FrbdName/snap/snapshotName/rollback'
+ );
+ expect(req.request.body).toEqual(null);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call cloneSnapshot', () => {
+ service
+ .cloneSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName', null)
+ .subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName/clone');
+ expect(req.request.body).toEqual(null);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call deleteSnapshot', () => {
+ service.deleteSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName').subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call moveTrash', () => {
+ service.moveTrash(new ImageSpec('poolName', null, 'rbdName'), 1).subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/move_trash');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ delay: 1 });
+ });
+
+ describe('should compose image spec', () => {
+ it('with namespace', () => {
+ expect(new ImageSpec('mypool', 'myns', 'myimage').toString()).toBe('mypool/myns/myimage');
+ });
+
+ it('without namespace', () => {
+ expect(new ImageSpec('mypool', null, 'myimage').toString()).toBe('mypool/myimage');
+ });
+ });
+
+ describe('should parse image spec', () => {
+ it('with namespace', () => {
+ const imageSpec = ImageSpec.fromString('mypool/myns/myimage');
+ expect(imageSpec.poolName).toBe('mypool');
+ expect(imageSpec.namespace).toBe('myns');
+ expect(imageSpec.imageName).toBe('myimage');
+ });
+
+ it('without namespace', () => {
+ const imageSpec = ImageSpec.fromString('mypool/myimage');
+ expect(imageSpec.poolName).toBe('mypool');
+ expect(imageSpec.namespace).toBeNull();
+ expect(imageSpec.imageName).toBe('myimage');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
new file mode 100644
index 000000000..555f0db0f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
@@ -0,0 +1,198 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { map } from 'rxjs/operators';
+
+import { ApiClient } from '~/app/shared/api/api-client';
+import { cdEncode, cdEncodeNot } from '../decorators/cd-encode';
+import { ImageSpec } from '../models/image-spec';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
+import { RbdPool } from './rbd.model';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class RbdService extends ApiClient {
+ constructor(private http: HttpClient, private rbdConfigurationService: RbdConfigurationService) {
+ super();
+ }
+
+ isRBDPool(pool: any) {
+ return _.indexOf(pool.application_metadata, 'rbd') !== -1 && !pool.pool_name.includes('/');
+ }
+
+ create(rbd: any) {
+ return this.http.post('api/block/image', rbd, { observe: 'response' });
+ }
+
+ delete(imageSpec: ImageSpec) {
+ return this.http.delete(`api/block/image/${imageSpec.toStringEncoded()}`, {
+ observe: 'response'
+ });
+ }
+
+ update(imageSpec: ImageSpec, rbd: any) {
+ return this.http.put(`api/block/image/${imageSpec.toStringEncoded()}`, rbd, {
+ observe: 'response'
+ });
+ }
+
+ get(imageSpec: ImageSpec) {
+ return this.http.get(`api/block/image/${imageSpec.toStringEncoded()}`);
+ }
+
+ list(params: any) {
+ return this.http
+ .get<RbdPool[]>('api/block/image', {
+ params: params,
+ headers: { Accept: this.getVersionHeaderValue(2, 0) },
+ observe: 'response'
+ })
+ .pipe(
+ map((response: any) => {
+ return response['body'].map((pool: any) => {
+ pool.value.map((image: any) => {
+ if (!image.configuration) {
+ return image;
+ }
+ image.configuration.map((option: any) =>
+ Object.assign(option, this.rbdConfigurationService.getOptionByName(option.name))
+ );
+ return image;
+ });
+ pool['headers'] = response.headers;
+ return pool;
+ });
+ })
+ );
+ }
+
+ copy(imageSpec: ImageSpec, rbd: any) {
+ return this.http.post(`api/block/image/${imageSpec.toStringEncoded()}/copy`, rbd, {
+ observe: 'response'
+ });
+ }
+
+ flatten(imageSpec: ImageSpec) {
+ return this.http.post(`api/block/image/${imageSpec.toStringEncoded()}/flatten`, null, {
+ observe: 'response'
+ });
+ }
+
+ defaultFeatures() {
+ return this.http.get('api/block/image/default_features');
+ }
+
+ cloneFormatVersion() {
+ return this.http.get<number>('api/block/image/clone_format_version');
+ }
+
+ createSnapshot(imageSpec: ImageSpec, @cdEncodeNot snapshotName: string) {
+ const request = {
+ snapshot_name: snapshotName
+ };
+ return this.http.post(`api/block/image/${imageSpec.toStringEncoded()}/snap`, request, {
+ observe: 'response'
+ });
+ }
+
+ renameSnapshot(imageSpec: ImageSpec, snapshotName: string, @cdEncodeNot newSnapshotName: string) {
+ const request = {
+ new_snap_name: newSnapshotName
+ };
+ return this.http.put(
+ `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}`,
+ request,
+ {
+ observe: 'response'
+ }
+ );
+ }
+
+ protectSnapshot(imageSpec: ImageSpec, snapshotName: string, @cdEncodeNot isProtected: boolean) {
+ const request = {
+ is_protected: isProtected
+ };
+ return this.http.put(
+ `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}`,
+ request,
+ {
+ observe: 'response'
+ }
+ );
+ }
+
+ rollbackSnapshot(imageSpec: ImageSpec, snapshotName: string) {
+ return this.http.post(
+ `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}/rollback`,
+ null,
+ { observe: 'response' }
+ );
+ }
+
+ cloneSnapshot(imageSpec: ImageSpec, snapshotName: string, request: any) {
+ return this.http.post(
+ `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}/clone`,
+ request,
+ { observe: 'response' }
+ );
+ }
+
+ deleteSnapshot(imageSpec: ImageSpec, snapshotName: string) {
+ return this.http.delete(`api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}`, {
+ observe: 'response'
+ });
+ }
+
+ listTrash() {
+ return this.http.get(`api/block/image/trash/`);
+ }
+
+ createNamespace(pool: string, namespace: string) {
+ const request = {
+ namespace: namespace
+ };
+ return this.http.post(`api/block/pool/${pool}/namespace`, request, { observe: 'response' });
+ }
+
+ listNamespaces(pool: string) {
+ return this.http.get(`api/block/pool/${pool}/namespace/`);
+ }
+
+ deleteNamespace(pool: string, namespace: string) {
+ return this.http.delete(`api/block/pool/${pool}/namespace/${namespace}`, {
+ observe: 'response'
+ });
+ }
+
+ moveTrash(imageSpec: ImageSpec, delay: number) {
+ return this.http.post(
+ `api/block/image/${imageSpec.toStringEncoded()}/move_trash`,
+ { delay: delay },
+ { observe: 'response' }
+ );
+ }
+
+ purgeTrash(poolName: string) {
+ return this.http.post(`api/block/image/trash/purge/?pool_name=${poolName}`, null, {
+ observe: 'response'
+ });
+ }
+
+ restoreTrash(imageSpec: ImageSpec, @cdEncodeNot newImageName: string) {
+ return this.http.post(
+ `api/block/image/trash/${imageSpec.toStringEncoded()}/restore`,
+ { new_image_name: newImageName },
+ { observe: 'response' }
+ );
+ }
+
+ removeTrash(imageSpec: ImageSpec, force = false) {
+ return this.http.delete(
+ `api/block/image/trash/${imageSpec.toStringEncoded()}/?force=${force}`,
+ { observe: 'response' }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts
new file mode 100644
index 000000000..b22b67e34
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts
@@ -0,0 +1,102 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper';
+import { RgwBucketService } from './rgw-bucket.service';
+
+describe('RgwBucketService', () => {
+ let service: RgwBucketService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [RgwBucketService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RgwBucketService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ RgwHelper.selectDaemon();
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne(`api/rgw/bucket?${RgwHelper.DAEMON_QUERY_PARAM}&stats=false`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call list with stats and user id', () => {
+ service.list(true, 'test-name').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/bucket?${RgwHelper.DAEMON_QUERY_PARAM}&stats=true&uid=test-name`
+ );
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call get', () => {
+ service.get('foo').subscribe();
+ const req = httpTesting.expectOne(`api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call create', () => {
+ service
+ .create('foo', 'bar', 'default', 'default-placement', false, 'COMPLIANCE', '5')
+ .subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=5&${RgwHelper.DAEMON_QUERY_PARAM}`
+ );
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call update', () => {
+ service
+ .update('foo', 'bar', 'baz', 'Enabled', 'Enabled', '1', '223344', 'GOVERNANCE', '10')
+ .subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=10`
+ );
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call delete, with purgeObjects = true', () => {
+ service.delete('foo').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&purge_objects=true`
+ );
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call delete, with purgeObjects = false', () => {
+ service.delete('foo', false).subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&purge_objects=false`
+ );
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call exists', () => {
+ let result;
+ service.exists('foo').subscribe((resp) => {
+ result = resp;
+ });
+ const req = httpTesting.expectOne(`api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ req.flush(['foo', 'bar']);
+ expect(result).toBe(true);
+ });
+
+ it('should convert lock retention period to days', () => {
+ expect(service.getLockDays({ lock_retention_period_years: 1000 })).toBe(365242);
+ expect(service.getLockDays({ lock_retention_period_days: 5 })).toBe(5);
+ expect(service.getLockDays({})).toBe(0);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
new file mode 100644
index 000000000..fc88bfa71
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
@@ -0,0 +1,128 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { of as observableOf } from 'rxjs';
+import { catchError, mapTo } from 'rxjs/operators';
+
+import { ApiClient } from '~/app/shared/api/api-client';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class RgwBucketService extends ApiClient {
+ private url = 'api/rgw/bucket';
+
+ constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {
+ super();
+ }
+
+ /**
+ * Get the list of buckets.
+ * @return Observable<Object[]>
+ */
+ list(stats: boolean = false, uid: string = '') {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('stats', stats.toString());
+ if (uid) {
+ params = params.append('uid', uid);
+ }
+ return this.http.get(this.url, {
+ headers: { Accept: this.getVersionHeaderValue(1, 1) },
+ params: params
+ });
+ });
+ }
+
+ get(bucket: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.get(`${this.url}/${bucket}`, { params: params });
+ });
+ }
+
+ create(
+ bucket: string,
+ uid: string,
+ zonegroup: string,
+ placementTarget: string,
+ lockEnabled: boolean,
+ lock_mode: 'GOVERNANCE' | 'COMPLIANCE',
+ lock_retention_period_days: string
+ ) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.post(this.url, null, {
+ params: new HttpParams({
+ fromObject: {
+ bucket,
+ uid,
+ zonegroup,
+ placement_target: placementTarget,
+ lock_enabled: String(lockEnabled),
+ lock_mode,
+ lock_retention_period_days,
+ daemon_name: params.get('daemon_name')
+ }
+ })
+ });
+ });
+ }
+
+ update(
+ bucket: string,
+ bucketId: string,
+ uid: string,
+ versioningState: string,
+ mfaDelete: string,
+ mfaTokenSerial: string,
+ mfaTokenPin: string,
+ lockMode: 'GOVERNANCE' | 'COMPLIANCE',
+ lockRetentionPeriodDays: string
+ ) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('bucket_id', bucketId);
+ params = params.append('uid', uid);
+ params = params.append('versioning_state', versioningState);
+ params = params.append('mfa_delete', mfaDelete);
+ params = params.append('mfa_token_serial', mfaTokenSerial);
+ params = params.append('mfa_token_pin', mfaTokenPin);
+ params = params.append('lock_mode', lockMode);
+ params = params.append('lock_retention_period_days', lockRetentionPeriodDays);
+ return this.http.put(`${this.url}/${bucket}`, null, { params: params });
+ });
+ }
+
+ delete(bucket: string, purgeObjects = true) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('purge_objects', purgeObjects ? 'true' : 'false');
+ return this.http.delete(`${this.url}/${bucket}`, { params: params });
+ });
+ }
+
+ /**
+ * Check if the specified bucket exists.
+ * @param {string} bucket The bucket name to check.
+ * @return Observable<boolean>
+ */
+ exists(bucket: string) {
+ return this.get(bucket).pipe(
+ mapTo(true),
+ catchError((error: Event) => {
+ if (_.isFunction(error.preventDefault)) {
+ error.preventDefault();
+ }
+ return observableOf(false);
+ })
+ );
+ }
+
+ getLockDays(bucketData: object): number {
+ if (bucketData['lock_retention_period_years'] > 0) {
+ return Math.floor(bucketData['lock_retention_period_years'] * 365.242);
+ }
+
+ return bucketData['lock_retention_period_days'] || 0;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts
new file mode 100644
index 000000000..d669ddefc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts
@@ -0,0 +1,90 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { of } from 'rxjs';
+
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper';
+import { RgwDaemonService } from './rgw-daemon.service';
+
+describe('RgwDaemonService', () => {
+ let service: RgwDaemonService;
+ let httpTesting: HttpTestingController;
+ let selectDaemonSpy: jasmine.Spy;
+
+ const daemonList: Array<RgwDaemon> = RgwHelper.getDaemonList();
+ const retrieveDaemonList = (reqDaemonList: RgwDaemon[], daemon: RgwDaemon) => {
+ service
+ .request((params) => of(params))
+ .subscribe((params) => expect(params.get('daemon_name')).toBe(daemon.id));
+ const listReq = httpTesting.expectOne('api/rgw/daemon');
+ listReq.flush(reqDaemonList);
+ tick();
+ expect(service['selectedDaemon'].getValue()).toEqual(daemon);
+ };
+
+ configureTestBed({
+ providers: [RgwDaemonService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RgwDaemonService);
+ selectDaemonSpy = spyOn(service, 'selectDaemon').and.callThrough();
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should get daemon list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/rgw/daemon');
+ req.flush(daemonList);
+ expect(req.request.method).toBe('GET');
+ expect(service['daemons'].getValue()).toEqual(daemonList);
+ });
+
+ it('should call "get daemon"', () => {
+ service.get('foo').subscribe();
+ const req = httpTesting.expectOne('api/rgw/daemon/foo');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call request and not select any daemon from empty daemon list', fakeAsync(() => {
+ expect(() => retrieveDaemonList([], null)).toThrowError('No RGW daemons found!');
+ expect(selectDaemonSpy).toHaveBeenCalledTimes(0);
+ }));
+
+ it('should call request and select default daemon from daemon list', fakeAsync(() => {
+ retrieveDaemonList(daemonList, daemonList[1]);
+ expect(selectDaemonSpy).toHaveBeenCalledTimes(1);
+ expect(selectDaemonSpy).toHaveBeenCalledWith(daemonList[1]);
+ }));
+
+ it('should call request and select first daemon from daemon list that has no default', fakeAsync(() => {
+ const noDefaultDaemonList = daemonList.map((daemon) => {
+ daemon.default = false;
+ return daemon;
+ });
+ retrieveDaemonList(noDefaultDaemonList, noDefaultDaemonList[0]);
+ expect(selectDaemonSpy).toHaveBeenCalledTimes(1);
+ expect(selectDaemonSpy).toHaveBeenCalledWith(noDefaultDaemonList[0]);
+ }));
+
+ it('should update default daemon if not exist in daemon list', fakeAsync(() => {
+ const tmpDaemonList = [...daemonList];
+ service.selectDaemon(tmpDaemonList[1]); // Select 'default' daemon.
+ tmpDaemonList.splice(1, 1); // Remove 'default' daemon.
+ tmpDaemonList[0].default = true; // Set new 'default' daemon.
+ service.list().subscribe();
+ const testReq = httpTesting.expectOne('api/rgw/daemon');
+ testReq.flush(tmpDaemonList);
+ expect(service['selectedDaemon'].getValue()).toEqual(tmpDaemonList[0]);
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts
new file mode 100644
index 000000000..5c513c7f1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts
@@ -0,0 +1,82 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { BehaviorSubject, Observable, of, throwError } from 'rxjs';
+import { mergeMap, take, tap } from 'rxjs/operators';
+
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class RgwDaemonService {
+ private url = 'api/rgw/daemon';
+ private daemons = new BehaviorSubject<RgwDaemon[]>([]);
+ daemons$ = this.daemons.asObservable();
+ private selectedDaemon = new BehaviorSubject<RgwDaemon>(null);
+ selectedDaemon$ = this.selectedDaemon.asObservable();
+
+ constructor(private http: HttpClient) {}
+
+ list(): Observable<RgwDaemon[]> {
+ return this.http.get<RgwDaemon[]>(this.url).pipe(
+ tap((daemons: RgwDaemon[]) => {
+ this.daemons.next(daemons);
+ const selectedDaemon = this.selectedDaemon.getValue();
+ // Set or re-select the default daemon if the current one is not
+ // in the list anymore.
+ if (_.isEmpty(selectedDaemon) || undefined === _.find(daemons, { id: selectedDaemon.id })) {
+ this.selectDefaultDaemon(daemons);
+ }
+ })
+ );
+ }
+
+ get(id: string) {
+ return this.http.get(`${this.url}/${id}`);
+ }
+
+ selectDaemon(daemon: RgwDaemon) {
+ this.selectedDaemon.next(daemon);
+ }
+
+ private selectDefaultDaemon(daemons: RgwDaemon[]): RgwDaemon {
+ if (daemons.length === 0) {
+ return null;
+ }
+
+ for (const daemon of daemons) {
+ if (daemon.default) {
+ this.selectDaemon(daemon);
+ return daemon;
+ }
+ }
+
+ this.selectDaemon(daemons[0]);
+ return daemons[0];
+ }
+
+ request(next: (params: HttpParams) => Observable<any>) {
+ return this.selectedDaemon.pipe(
+ mergeMap((daemon: RgwDaemon) =>
+ // If there is no selected daemon, retrieve daemon list so default daemon will be selected.
+ _.isEmpty(daemon)
+ ? this.list().pipe(
+ mergeMap((daemons) =>
+ _.isEmpty(daemons) ? throwError('No RGW daemons found!') : this.selectedDaemon$
+ )
+ )
+ : of(daemon)
+ ),
+ take(1),
+ mergeMap((daemon: RgwDaemon) => {
+ let params = new HttpParams();
+ params = params.append('daemon_name', daemon.id);
+ return next(params);
+ })
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts
new file mode 100644
index 000000000..fa769d88b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts
@@ -0,0 +1,43 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper';
+import { RgwSiteService } from './rgw-site.service';
+
+describe('RgwSiteService', () => {
+ let service: RgwSiteService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [RgwSiteService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RgwSiteService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ RgwHelper.selectDaemon();
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should contain site endpoint in GET request', () => {
+ service.get().subscribe();
+ const req = httpTesting.expectOne(`${service['url']}?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should add query param in GET request', () => {
+ const query = 'placement-targets';
+ service.get(query).subscribe();
+ httpTesting.expectOne(
+ `${service['url']}?${RgwHelper.DAEMON_QUERY_PARAM}&query=placement-targets`
+ );
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts
new file mode 100644
index 000000000..49589c83f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts
@@ -0,0 +1,38 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+import { map, mergeMap } from 'rxjs/operators';
+
+import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class RgwSiteService {
+ private url = 'api/rgw/site';
+
+ constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {}
+
+ get(query?: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ if (query) {
+ params = params.append('query', query);
+ }
+ return this.http.get(this.url, { params: params });
+ });
+ }
+
+ isDefaultRealm(): Observable<boolean> {
+ return this.get('default-realm').pipe(
+ mergeMap((defaultRealm: string) =>
+ this.rgwDaemonService.selectedDaemon$.pipe(
+ map((selectedDaemon: RgwDaemon) => selectedDaemon.realm_name === defaultRealm)
+ )
+ )
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts
new file mode 100644
index 000000000..7884f2385
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts
@@ -0,0 +1,170 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { of as observableOf, throwError } from 'rxjs';
+
+import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper';
+import { RgwUserService } from './rgw-user.service';
+
+describe('RgwUserService', () => {
+ let service: RgwUserService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [RgwUserService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RgwUserService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ RgwHelper.selectDaemon();
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list with empty result', () => {
+ let result;
+ service.list().subscribe((resp) => {
+ result = resp;
+ });
+ const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ req.flush([]);
+ expect(result).toEqual([]);
+ });
+
+ it('should call list with result', () => {
+ let result;
+ service.list().subscribe((resp) => {
+ result = resp;
+ });
+ let req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ req.flush(['foo', 'bar']);
+
+ req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ req.flush({ name: 'foo' });
+
+ req = httpTesting.expectOne(`api/rgw/user/bar?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ req.flush({ name: 'bar' });
+
+ expect(result).toEqual([{ name: 'foo' }, { name: 'bar' }]);
+ });
+
+ it('should call enumerate', () => {
+ service.enumerate().subscribe();
+ const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call get', () => {
+ service.get('foo').subscribe();
+ const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call getQuota', () => {
+ service.getQuota('foo').subscribe();
+ const req = httpTesting.expectOne(`api/rgw/user/foo/quota?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call update', () => {
+ service.update('foo', { xxx: 'yyy' }).subscribe();
+ const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy`);
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call updateQuota', () => {
+ service.updateQuota('foo', { xxx: 'yyy' }).subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/quota?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy`
+ );
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call create', () => {
+ service.create({ foo: 'bar' }).subscribe();
+ const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}&foo=bar`);
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call delete', () => {
+ service.delete('foo').subscribe();
+ const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call createSubuser', () => {
+ service.createSubuser('foo', { xxx: 'yyy' }).subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/subuser?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy`
+ );
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call deleteSubuser', () => {
+ service.deleteSubuser('foo', 'bar').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/subuser/bar?${RgwHelper.DAEMON_QUERY_PARAM}`
+ );
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call addCapability', () => {
+ service.addCapability('foo', 'bar', 'baz').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/capability?${RgwHelper.DAEMON_QUERY_PARAM}&type=bar&perm=baz`
+ );
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call deleteCapability', () => {
+ service.deleteCapability('foo', 'bar', 'baz').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/capability?${RgwHelper.DAEMON_QUERY_PARAM}&type=bar&perm=baz`
+ );
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call addS3Key', () => {
+ service.addS3Key('foo', { xxx: 'yyy' }).subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/key?${RgwHelper.DAEMON_QUERY_PARAM}&key_type=s3&xxx=yyy`
+ );
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call deleteS3Key', () => {
+ service.deleteS3Key('foo', 'bar').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/user/foo/key?${RgwHelper.DAEMON_QUERY_PARAM}&key_type=s3&access_key=bar`
+ );
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call exists with an existent uid', (done) => {
+ spyOn(service, 'get').and.returnValue(observableOf({}));
+ service.exists('foo').subscribe((res) => {
+ expect(res).toBe(true);
+ done();
+ });
+ });
+
+ it('should call exists with a non existent uid', (done) => {
+ spyOn(service, 'get').and.returnValue(throwError('bar'));
+ service.exists('baz').subscribe((res) => {
+ expect(res).toBe(false);
+ done();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts
new file mode 100644
index 000000000..66167bcab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts
@@ -0,0 +1,179 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { forkJoin as observableForkJoin, Observable, of as observableOf } from 'rxjs';
+import { catchError, mapTo, mergeMap } from 'rxjs/operators';
+
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { cdEncode } from '~/app/shared/decorators/cd-encode';
+
+@cdEncode
+@Injectable({
+ providedIn: 'root'
+})
+export class RgwUserService {
+ private url = 'api/rgw/user';
+
+ constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {}
+
+ /**
+ * Get the list of users.
+ * @return {Observable<Object[]>}
+ */
+ list() {
+ return this.enumerate().pipe(
+ mergeMap((uids: string[]) => {
+ if (uids.length > 0) {
+ return observableForkJoin(
+ uids.map((uid: string) => {
+ return this.get(uid);
+ })
+ );
+ }
+ return observableOf([]);
+ })
+ );
+ }
+
+ /**
+ * Get the list of usernames.
+ * @return {Observable<string[]>}
+ */
+ enumerate() {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.get(this.url, { params: params });
+ });
+ }
+
+ enumerateEmail() {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.get(`${this.url}/get_emails`, { params: params });
+ });
+ }
+
+ get(uid: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.get(`${this.url}/${uid}`, { params: params });
+ });
+ }
+
+ getQuota(uid: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.get(`${this.url}/${uid}/quota`, { params: params });
+ });
+ }
+
+ create(args: Record<string, any>) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ _.keys(args).forEach((key) => {
+ params = params.append(key, args[key]);
+ });
+ return this.http.post(this.url, null, { params: params });
+ });
+ }
+
+ update(uid: string, args: Record<string, any>) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ _.keys(args).forEach((key) => {
+ params = params.append(key, args[key]);
+ });
+ return this.http.put(`${this.url}/${uid}`, null, { params: params });
+ });
+ }
+
+ updateQuota(uid: string, args: Record<string, string>) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ _.keys(args).forEach((key) => {
+ params = params.append(key, args[key]);
+ });
+ return this.http.put(`${this.url}/${uid}/quota`, null, { params: params });
+ });
+ }
+
+ delete(uid: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.delete(`${this.url}/${uid}`, { params: params });
+ });
+ }
+
+ createSubuser(uid: string, args: Record<string, string>) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ _.keys(args).forEach((key) => {
+ params = params.append(key, args[key]);
+ });
+ return this.http.post(`${this.url}/${uid}/subuser`, null, { params: params });
+ });
+ }
+
+ deleteSubuser(uid: string, subuser: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ return this.http.delete(`${this.url}/${uid}/subuser/${subuser}`, { params: params });
+ });
+ }
+
+ addCapability(uid: string, type: string, perm: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('type', type);
+ params = params.append('perm', perm);
+ return this.http.post(`${this.url}/${uid}/capability`, null, { params: params });
+ });
+ }
+
+ deleteCapability(uid: string, type: string, perm: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('type', type);
+ params = params.append('perm', perm);
+ return this.http.delete(`${this.url}/${uid}/capability`, { params: params });
+ });
+ }
+
+ addS3Key(uid: string, args: Record<string, string>) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('key_type', 's3');
+ _.keys(args).forEach((key) => {
+ params = params.append(key, args[key]);
+ });
+ return this.http.post(`${this.url}/${uid}/key`, null, { params: params });
+ });
+ }
+
+ deleteS3Key(uid: string, accessKey: string) {
+ return this.rgwDaemonService.request((params: HttpParams) => {
+ params = params.append('key_type', 's3');
+ params = params.append('access_key', accessKey);
+ return this.http.delete(`${this.url}/${uid}/key`, { params: params });
+ });
+ }
+
+ /**
+ * Check if the specified user ID exists.
+ * @param {string} uid The user ID to check.
+ * @return {Observable<boolean>}
+ */
+ exists(uid: string): Observable<boolean> {
+ return this.get(uid).pipe(
+ mapTo(true),
+ catchError((error: Event) => {
+ if (_.isFunction(error.preventDefault)) {
+ error.preventDefault();
+ }
+ return observableOf(false);
+ })
+ );
+ }
+
+ // Using @cdEncodeNot would be the preferred way here, but this
+ // causes an error: https://tracker.ceph.com/issues/37505
+ // Use decodeURIComponent as workaround.
+ // emailExists(@cdEncodeNot email: string): Observable<boolean> {
+ emailExists(email: string): Observable<boolean> {
+ email = decodeURIComponent(email);
+ return this.enumerateEmail().pipe(
+ mergeMap((resp: any[]) => {
+ const index = _.indexOf(resp, email);
+ return observableOf(-1 !== index);
+ })
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts
new file mode 100644
index 000000000..c5af5877c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts
@@ -0,0 +1,75 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RoleService } from './role.service';
+
+describe('RoleService', () => {
+ let service: RoleService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [RoleService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RoleService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/role');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call delete', () => {
+ service.delete('role1').subscribe();
+ const req = httpTesting.expectOne('api/role/role1');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call get', () => {
+ service.get('role1').subscribe();
+ const req = httpTesting.expectOne('api/role/role1');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call clone', () => {
+ service.clone('foo', 'bar').subscribe();
+ const req = httpTesting.expectOne('api/role/foo/clone');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ new_name: 'bar' });
+ });
+
+ it('should check if role name exists', () => {
+ let exists: boolean;
+ service.exists('role1').subscribe((res: boolean) => {
+ exists = res;
+ });
+ const req = httpTesting.expectOne('api/role');
+ expect(req.request.method).toBe('GET');
+ req.flush([{ name: 'role0' }, { name: 'role1' }]);
+ expect(exists).toBeTruthy();
+ });
+
+ it('should check if role name does not exist', () => {
+ let exists: boolean;
+ service.exists('role2').subscribe((res: boolean) => {
+ exists = res;
+ });
+ const req = httpTesting.expectOne('api/role');
+ expect(req.request.method).toBe('GET');
+ req.flush([{ name: 'role0' }, { name: 'role1' }]);
+ expect(exists).toBeFalsy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts
new file mode 100644
index 000000000..e76846b41
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts
@@ -0,0 +1,49 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable, of as observableOf } from 'rxjs';
+import { mergeMap } from 'rxjs/operators';
+
+import { RoleFormModel } from '~/app/core/auth/role-form/role-form.model';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class RoleService {
+ constructor(private http: HttpClient) {}
+
+ list() {
+ return this.http.get('api/role');
+ }
+
+ delete(name: string) {
+ return this.http.delete(`api/role/${name}`);
+ }
+
+ get(name: string) {
+ return this.http.get(`api/role/${name}`);
+ }
+
+ create(role: RoleFormModel) {
+ return this.http.post(`api/role`, role);
+ }
+
+ clone(name: string, newName: string) {
+ return this.http.post(`api/role/${name}/clone`, { new_name: newName });
+ }
+
+ update(role: RoleFormModel) {
+ return this.http.put(`api/role/${role.name}`, role);
+ }
+
+ exists(name: string): Observable<boolean> {
+ return this.list().pipe(
+ mergeMap((roles: Array<RoleFormModel>) => {
+ const exists = roles.some((currentRole: RoleFormModel) => {
+ return currentRole.name === name;
+ });
+ return observableOf(exists);
+ })
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts
new file mode 100644
index 000000000..811e1924f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ScopeService } from './scope.service';
+
+describe('ScopeService', () => {
+ let service: ScopeService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [ScopeService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(ScopeService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('ui-api/scope');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts
new file mode 100644
index 000000000..11e5da80a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts
@@ -0,0 +1,13 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ScopeService {
+ constructor(private http: HttpClient) {}
+
+ list() {
+ return this.http.get('ui-api/scope');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts
new file mode 100644
index 000000000..06bd19823
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts
@@ -0,0 +1,154 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SettingsService } from './settings.service';
+
+describe('SettingsService', () => {
+ let service: SettingsService;
+ let httpTesting: HttpTestingController;
+
+ const exampleUrl = 'api/settings/something';
+ const exampleValue = 'http://localhost:3000';
+
+ configureTestBed({
+ providers: [SettingsService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(SettingsService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call validateGrafanaDashboardUrl', () => {
+ service.validateGrafanaDashboardUrl('s').subscribe();
+ const req = httpTesting.expectOne('api/grafana/validation/s');
+ expect(req.request.method).toBe('GET');
+ });
+
+ describe('getSettingsValue', () => {
+ const testMethod = (data: object, expected: string) => {
+ expect(service['getSettingsValue'](data)).toBe(expected);
+ };
+
+ it('should explain the logic of the method', () => {
+ expect('' || undefined).toBe(undefined);
+ expect(undefined || '').toBe('');
+ expect('test' || undefined || '').toBe('test');
+ });
+
+ it('should test the method for empty string values', () => {
+ testMethod({}, '');
+ testMethod({ wrongAttribute: 'test' }, '');
+ testMethod({ value: '' }, '');
+ testMethod({ instance: '' }, '');
+ });
+
+ it('should test the method for non empty string values', () => {
+ testMethod({ value: 'test' }, 'test');
+ testMethod({ instance: 'test' }, 'test');
+ });
+ });
+
+ describe('isSettingConfigured', () => {
+ let increment: number;
+
+ const testConfig = (url: string, value: string) => {
+ service.ifSettingConfigured(
+ url,
+ (setValue) => {
+ expect(setValue).toBe(value);
+ increment++;
+ },
+ () => {
+ increment--;
+ }
+ );
+ };
+
+ const expectSettingsApiCall = (url: string, value: object, isSet: string) => {
+ testConfig(url, isSet);
+ const req = httpTesting.expectOne(url);
+ expect(req.request.method).toBe('GET');
+ req.flush(value);
+ tick();
+ expect(increment).toBe(isSet !== '' ? 1 : -1);
+ expect(service['settings'][url]).toBe(isSet);
+ };
+
+ beforeEach(() => {
+ increment = 0;
+ });
+
+ it(`should return true if 'value' does not contain an empty string`, fakeAsync(() => {
+ expectSettingsApiCall(exampleUrl, { value: exampleValue }, exampleValue);
+ }));
+
+ it(`should return false if 'value' does contain an empty string`, fakeAsync(() => {
+ expectSettingsApiCall(exampleUrl, { value: '' }, '');
+ }));
+
+ it(`should return true if 'instance' does not contain an empty string`, fakeAsync(() => {
+ expectSettingsApiCall(exampleUrl, { value: exampleValue }, exampleValue);
+ }));
+
+ it(`should return false if 'instance' does contain an empty string`, fakeAsync(() => {
+ expectSettingsApiCall(exampleUrl, { instance: '' }, '');
+ }));
+
+ it(`should return false if the api object is empty`, fakeAsync(() => {
+ expectSettingsApiCall(exampleUrl, {}, '');
+ }));
+
+ it(`should call the API once even if it is called multiple times`, fakeAsync(() => {
+ expectSettingsApiCall(exampleUrl, { value: exampleValue }, exampleValue);
+ testConfig(exampleUrl, exampleValue);
+ httpTesting.expectNone(exampleUrl);
+ expect(increment).toBe(2);
+ }));
+ });
+
+ it('should disable a set setting', () => {
+ service['settings'] = { [exampleUrl]: exampleValue };
+ service.disableSetting(exampleUrl);
+ expect(service['settings']).toEqual({ [exampleUrl]: '' });
+ });
+
+ it('should return the specified settings (1)', () => {
+ let result;
+ service.getValues('foo,bar').subscribe((resp) => {
+ result = resp;
+ });
+ const req = httpTesting.expectOne('api/settings?names=foo,bar');
+ expect(req.request.method).toBe('GET');
+ req.flush([
+ { name: 'foo', default: '', type: 'str', value: 'test' },
+ { name: 'bar', default: 0, type: 'int', value: 2 }
+ ]);
+ expect(result).toEqual({
+ foo: 'test',
+ bar: 2
+ });
+ });
+
+ it('should return the specified settings (2)', () => {
+ service.getValues(['abc', 'xyz']).subscribe();
+ const req = httpTesting.expectOne('api/settings?names=abc,xyz');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should return standard settings', () => {
+ service.getStandardSettings().subscribe();
+ const req = httpTesting.expectOne('ui-api/standard_settings');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts
new file mode 100644
index 000000000..1e53fa064
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts
@@ -0,0 +1,77 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+class SettingResponse {
+ name: string;
+ default: any;
+ type: string;
+ value: any;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class SettingsService {
+ constructor(private http: HttpClient) {}
+
+ private settings: { [url: string]: string } = {};
+
+ getValues(names: string | string[]): Observable<{ [key: string]: any }> {
+ if (_.isArray(names)) {
+ names = names.join(',');
+ }
+ return this.http.get(`api/settings?names=${names}`).pipe(
+ map((resp: SettingResponse[]) => {
+ const result = {};
+ _.forEach(resp, (option: SettingResponse) => {
+ _.set(result, option.name, option.value);
+ });
+ return result;
+ })
+ );
+ }
+
+ ifSettingConfigured(url: string, fn: (value?: string) => void, elseFn?: () => void): void {
+ const setting = this.settings[url];
+ if (setting === undefined) {
+ this.http.get(url).subscribe(
+ (data: any) => {
+ this.settings[url] = this.getSettingsValue(data);
+ this.ifSettingConfigured(url, fn, elseFn);
+ },
+ (resp) => {
+ if (resp.status !== 401) {
+ this.settings[url] = '';
+ }
+ }
+ );
+ } else if (setting !== '') {
+ fn(setting);
+ } else {
+ if (elseFn) {
+ elseFn();
+ }
+ }
+ }
+
+ // Easiest way to stop reloading external content that can't be reached
+ disableSetting(url: string) {
+ this.settings[url] = '';
+ }
+
+ private getSettingsValue(data: any): string {
+ return data.value || data.instance || '';
+ }
+
+ validateGrafanaDashboardUrl(uid: string) {
+ return this.http.get(`api/grafana/validation/${uid}`);
+ }
+
+ getStandardSettings(): Observable<{ [key: string]: any }> {
+ return this.http.get('ui-api/standard_settings');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts
new file mode 100644
index 000000000..a90fcff7a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts
@@ -0,0 +1,58 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TelemetryService } from './telemetry.service';
+
+describe('TelemetryService', () => {
+ let service: TelemetryService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [TelemetryService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TelemetryService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call getReport', () => {
+ service.getReport().subscribe();
+ const req = httpTesting.expectOne('api/telemetry/report');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call enable to enable module', () => {
+ service.enable(true).subscribe();
+ const req = httpTesting.expectOne('api/telemetry');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body.enable).toBe(true);
+ expect(req.request.body.license_name).toBe('sharing-1-0');
+ });
+
+ it('should call enable to disable module', () => {
+ service.enable(false).subscribe();
+ const req = httpTesting.expectOne('api/telemetry');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body.enable).toBe(false);
+ expect(req.request.body.license_name).toBeUndefined();
+ });
+
+ it('should call enable to enable module by default', () => {
+ service.enable().subscribe();
+ const req = httpTesting.expectOne('api/telemetry');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body.enable).toBe(true);
+ expect(req.request.body.license_name).toBe('sharing-1-0');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts
new file mode 100644
index 000000000..8a175f66d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts
@@ -0,0 +1,23 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TelemetryService {
+ private url = 'api/telemetry';
+
+ constructor(private http: HttpClient) {}
+
+ getReport() {
+ return this.http.get(`${this.url}/report`);
+ }
+
+ enable(enable: boolean = true) {
+ const body = { enable: enable };
+ if (enable) {
+ body['license_name'] = 'sharing-1-0';
+ }
+ return this.http.put(`${this.url}`, body);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts
new file mode 100644
index 000000000..ba038a725
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts
@@ -0,0 +1,104 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { UserFormModel } from '~/app/core/auth/user-form/user-form.model';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { UserService } from './user.service';
+
+describe('UserService', () => {
+ let service: UserService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [UserService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(UserService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call create', () => {
+ const user = new UserFormModel();
+ user.username = 'user0';
+ user.password = 'pass0';
+ user.name = 'User 0';
+ user.email = 'user0@email.com';
+ user.roles = ['administrator'];
+ service.create(user).subscribe();
+ const req = httpTesting.expectOne('api/user');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(user);
+ });
+
+ it('should call delete', () => {
+ service.delete('user0').subscribe();
+ const req = httpTesting.expectOne('api/user/user0');
+ expect(req.request.method).toBe('DELETE');
+ });
+
+ it('should call update', () => {
+ const user = new UserFormModel();
+ user.username = 'user0';
+ user.password = 'pass0';
+ user.name = 'User 0';
+ user.email = 'user0@email.com';
+ user.roles = ['administrator'];
+ service.update(user).subscribe();
+ const req = httpTesting.expectOne('api/user/user0');
+ expect(req.request.body).toEqual(user);
+ expect(req.request.method).toBe('PUT');
+ });
+
+ it('should call get', () => {
+ service.get('user0').subscribe();
+ const req = httpTesting.expectOne('api/user/user0');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/user');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call changePassword', () => {
+ service.changePassword('user0', 'foo', 'bar').subscribe();
+ const req = httpTesting.expectOne('api/user/user0/change_password');
+ expect(req.request.body).toEqual({
+ old_password: 'foo',
+ new_password: 'bar'
+ });
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call validatePassword', () => {
+ service.validatePassword('foo').subscribe();
+ const req = httpTesting.expectOne('api/user/validate_password');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ password: 'foo', old_password: null, username: null });
+ });
+
+ it('should call validatePassword (incl. name)', () => {
+ service.validatePassword('foo_bar', 'bar').subscribe();
+ const req = httpTesting.expectOne('api/user/validate_password');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ password: 'foo_bar', username: 'bar', old_password: null });
+ });
+
+ it('should call validatePassword (incl. old password)', () => {
+ service.validatePassword('foo', null, 'foo').subscribe();
+ const req = httpTesting.expectOne('api/user/validate_password');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ password: 'foo', old_password: 'foo', username: null });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts
new file mode 100644
index 000000000..95c80dd46
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts
@@ -0,0 +1,62 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable, of as observableOf } from 'rxjs';
+import { catchError, mapTo } from 'rxjs/operators';
+
+import { UserFormModel } from '~/app/core/auth/user-form/user-form.model';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class UserService {
+ constructor(private http: HttpClient) {}
+
+ list() {
+ return this.http.get('api/user');
+ }
+
+ delete(username: string) {
+ return this.http.delete(`api/user/${username}`);
+ }
+
+ get(username: string) {
+ return this.http.get(`api/user/${username}`);
+ }
+
+ create(user: UserFormModel) {
+ return this.http.post(`api/user`, user);
+ }
+
+ update(user: UserFormModel) {
+ return this.http.put(`api/user/${user.username}`, user);
+ }
+
+ changePassword(username: string, oldPassword: string, newPassword: string) {
+ // Note, the specified user MUST be logged in to be able to change
+ // the password. The backend ensures that the password of another
+ // user can not be changed, otherwise an error will be thrown.
+ return this.http.post(`api/user/${username}/change_password`, {
+ old_password: oldPassword,
+ new_password: newPassword
+ });
+ }
+
+ validateUserName(user_name: string): Observable<boolean> {
+ return this.get(user_name).pipe(
+ mapTo(true),
+ catchError((error) => {
+ error.preventDefault();
+ return observableOf(false);
+ })
+ );
+ }
+
+ validatePassword(password: string, username: string = null, oldPassword: string = null) {
+ return this.http.post('api/user/validate_password', {
+ password: password,
+ username: username,
+ old_password: oldPassword
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts
new file mode 100644
index 000000000..a5a28650d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts
@@ -0,0 +1,66 @@
+import { CdHelperClass } from './cd-helper.class';
+
+class MockClass {
+ n = 42;
+ o = {
+ x: 'something',
+ y: [1, 2, 3],
+ z: true
+ };
+ b: boolean;
+}
+
+describe('CdHelperClass', () => {
+ describe('updateChanged', () => {
+ let old: MockClass;
+ let used: MockClass;
+ let structure = {
+ n: 42,
+ o: {
+ x: 'something',
+ y: [1, 2, 3],
+ z: true
+ }
+ } as any;
+
+ beforeEach(() => {
+ old = new MockClass();
+ used = new MockClass();
+ structure = {
+ n: 42,
+ o: {
+ x: 'something',
+ y: [1, 2, 3],
+ z: true
+ }
+ };
+ });
+
+ it('should not update anything', () => {
+ CdHelperClass.updateChanged(used, structure);
+ expect(used).toEqual(old);
+ });
+
+ it('should only change n', () => {
+ CdHelperClass.updateChanged(used, { n: 17 });
+ expect(used.n).not.toEqual(old.n);
+ expect(used.n).toBe(17);
+ });
+
+ it('should update o on change of o.y', () => {
+ CdHelperClass.updateChanged(used, structure);
+ structure.o.y.push(4);
+ expect(used.o.y).toEqual(old.o.y);
+ CdHelperClass.updateChanged(used, structure);
+ expect(used.o.y).toEqual([1, 2, 3, 4]);
+ });
+
+ it('should change b, o and n', () => {
+ structure.o.x.toUpperCase();
+ structure.n++;
+ structure.b = true;
+ CdHelperClass.updateChanged(used, structure);
+ expect(used).toEqual(structure);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts
new file mode 100644
index 000000000..250573125
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts
@@ -0,0 +1,28 @@
+import _ from 'lodash';
+
+export class CdHelperClass {
+ /**
+ * Simple way to only update variables if they have really changed and not just the reference
+ *
+ * @param componentThis - In order to update the variables if necessary
+ * @param change - The variable name (attribute of the object) is followed by the current value
+ * it would update even if it equals
+ */
+ static updateChanged(componentThis: any, change: { [publicVarName: string]: any }) {
+ let hasChanges = false;
+
+ Object.keys(change).forEach((publicVarName) => {
+ const data = change[publicVarName];
+ if (!_.isEqual(data, componentThis[publicVarName])) {
+ componentThis[publicVarName] = data;
+ hasChanges = true;
+ }
+ });
+
+ return hasChanges;
+ }
+
+ static cdVersionHeader(major_ver: string, minor_ver: string) {
+ return `application/vnd.ceph.api.v${major_ver}.${minor_ver}+json`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts
new file mode 100644
index 000000000..e09364015
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts
@@ -0,0 +1,220 @@
+import { FormControl } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { configureTestBed, Mocks } from '~/testing/unit-test-helper';
+import { CrushNode } from '../models/crush-node';
+import { CrushNodeSelectionClass } from './crush.node.selection.class';
+
+describe('CrushNodeSelectionService', () => {
+ const nodes = Mocks.getCrushMap();
+
+ let service: CrushNodeSelectionClass;
+ let controls: {
+ root: FormControl;
+ failure: FormControl;
+ device: FormControl;
+ };
+
+ // Object contains functions to get something
+ const get = {
+ nodeByName: (name: string): CrushNode => nodes.find((node) => node.name === name),
+ nodesByNames: (names: string[]): CrushNode[] => names.map(get.nodeByName)
+ };
+
+ // Expects that are used frequently
+ const assert = {
+ formFieldValues: (root: CrushNode, failureDomain: string, device: string) => {
+ expect(controls.root.value).toEqual(root);
+ expect(controls.failure.value).toBe(failureDomain);
+ expect(controls.device.value).toBe(device);
+ },
+ valuesOnRootChange: (
+ rootName: string,
+ expectedFailureDomain: string,
+ expectedDevice: string
+ ) => {
+ const node = get.nodeByName(rootName);
+ controls.root.setValue(node);
+ assert.formFieldValues(node, expectedFailureDomain, expectedDevice);
+ },
+ failureDomainNodes: (
+ failureDomains: { [failureDomain: string]: CrushNode[] },
+ expected: { [failureDomains: string]: string[] | CrushNode[] }
+ ) => {
+ expect(Object.keys(failureDomains)).toEqual(Object.keys(expected));
+ Object.keys(failureDomains).forEach((key) => {
+ if (_.isString(expected[key][0])) {
+ expect(failureDomains[key]).toEqual(get.nodesByNames(expected[key] as string[]));
+ } else {
+ expect(failureDomains[key]).toEqual(expected[key]);
+ }
+ });
+ }
+ };
+
+ configureTestBed({
+ providers: [CrushNodeSelectionClass]
+ });
+
+ beforeEach(() => {
+ controls = {
+ root: new FormControl(null),
+ failure: new FormControl(''),
+ device: new FormControl('')
+ };
+ // Normally this should be extended by the class using it
+ service = new CrushNodeSelectionClass();
+ // Therefore to get it working correctly use "this" instead of "service"
+ service.initCrushNodeSelection(nodes, controls.root, controls.failure, controls.device);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ expect(nodes.length).toBe(12);
+ });
+
+ describe('lists', () => {
+ afterEach(() => {
+ // The available buckets should not change
+ expect(service.buckets).toEqual(
+ get.nodesByNames(['default', 'hdd-rack', 'mix-host', 'ssd-host', 'ssd-rack'])
+ );
+ });
+
+ it('has the following lists after init', () => {
+ assert.failureDomainNodes(service.failureDomains, {
+ host: ['ssd-host', 'mix-host'],
+ osd: ['osd.1', 'osd.0', 'osd.2'],
+ rack: ['hdd-rack', 'ssd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1']
+ });
+ expect(service.devices).toEqual(['hdd', 'ssd']);
+ });
+
+ it('has the following lists after selection of ssd-host', () => {
+ controls.root.setValue(get.nodeByName('ssd-host'));
+ assert.failureDomainNodes(service.failureDomains, {
+ // Not host as it only exist once
+ osd: ['osd.1', 'osd.0', 'osd.2']
+ });
+ expect(service.devices).toEqual(['ssd']);
+ });
+
+ it('has the following lists after selection of mix-host', () => {
+ controls.root.setValue(get.nodeByName('mix-host'));
+ expect(service.devices).toEqual(['hdd', 'ssd']);
+ assert.failureDomainNodes(service.failureDomains, {
+ rack: ['hdd-rack', 'ssd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1']
+ });
+ });
+ });
+
+ describe('selection', () => {
+ it('selects the first root after init automatically', () => {
+ assert.formFieldValues(get.nodeByName('default'), 'osd-rack', '');
+ });
+
+ it('should select all values automatically by selecting "ssd-host" as root', () => {
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ });
+
+ it('selects automatically the most common failure domain', () => {
+ // Select mix-host as mix-host has multiple failure domains (osd-rack and rack)
+ assert.valuesOnRootChange('mix-host', 'osd-rack', '');
+ });
+
+ it('should override automatic selections', () => {
+ assert.formFieldValues(get.nodeByName('default'), 'osd-rack', '');
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ assert.valuesOnRootChange('mix-host', 'osd-rack', '');
+ });
+
+ it('should not override manual selections if possible', () => {
+ controls.failure.setValue('rack');
+ controls.failure.markAsDirty();
+ controls.device.setValue('ssd');
+ controls.device.markAsDirty();
+ assert.valuesOnRootChange('mix-host', 'rack', 'ssd');
+ });
+
+ it('should preselect device by domain selection', () => {
+ controls.failure.setValue('osd');
+ assert.formFieldValues(get.nodeByName('default'), 'osd', 'ssd');
+ });
+ });
+
+ describe('get available OSDs count', () => {
+ it('should have 4 available OSDs with the default selection', () => {
+ expect(service.deviceCount).toBe(4);
+ });
+
+ it('should reduce available OSDs to 2 if a device type is set', () => {
+ controls.device.setValue('ssd');
+ controls.device.markAsDirty();
+ expect(service.deviceCount).toBe(2);
+ });
+
+ it('should show 3 OSDs when selecting "ssd-host"', () => {
+ assert.valuesOnRootChange('ssd-host', 'osd', 'ssd');
+ expect(service.deviceCount).toBe(3);
+ });
+ });
+
+ describe('search tree', () => {
+ it('returns the following list after searching for mix-host', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host');
+ expect(subNodes).toEqual(
+ get.nodesByNames([
+ 'mix-host',
+ 'hdd-rack',
+ 'osd2.0',
+ 'osd2.1',
+ 'ssd-rack',
+ 'osd3.0',
+ 'osd3.1'
+ ])
+ );
+ });
+
+ it('returns the following list after searching for mix-host with SSDs', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host~ssd');
+ expect(subNodes.map((n) => n.name)).toEqual(['mix-host', 'ssd-rack', 'osd3.0', 'osd3.1']);
+ });
+
+ it('returns an empty array if node can not be found', () => {
+ expect(CrushNodeSelectionClass.search(nodes, 'not-there')).toEqual([]);
+ });
+
+ it('returns the following list after searching for mix-host failure domains', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host');
+ assert.failureDomainNodes(CrushNodeSelectionClass.getFailureDomains(subNodes), {
+ host: ['mix-host'],
+ rack: ['hdd-rack', 'ssd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1']
+ });
+ });
+
+ it('returns the following list after searching for mix-host failure domains for a specific type', () => {
+ const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host~hdd');
+ const hddHost = _.cloneDeep(get.nodesByNames(['mix-host'])[0]);
+ hddHost.children = [-4];
+ assert.failureDomainNodes(CrushNodeSelectionClass.getFailureDomains(subNodes), {
+ host: [hddHost],
+ rack: ['hdd-rack'],
+ 'osd-rack': ['osd2.0', 'osd2.1']
+ });
+ const ssdHost = _.cloneDeep(get.nodesByNames(['mix-host'])[0]);
+ ssdHost.children = [-5];
+ assert.failureDomainNodes(
+ CrushNodeSelectionClass.searchFailureDomains(nodes, 'mix-host~ssd'),
+ {
+ host: [ssdHost],
+ rack: ['ssd-rack'],
+ 'osd-rack': ['osd3.0', 'osd3.1']
+ }
+ );
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts
new file mode 100644
index 000000000..34cebbcc8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts
@@ -0,0 +1,221 @@
+import { AbstractControl } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { CrushNode } from '../models/crush-node';
+
+export class CrushNodeSelectionClass {
+ private nodes: CrushNode[] = [];
+ private idTree: { [id: number]: CrushNode } = {};
+ private allDevices: string[] = [];
+ private controls: {
+ root: AbstractControl;
+ failure: AbstractControl;
+ device: AbstractControl;
+ };
+
+ buckets: CrushNode[] = [];
+ failureDomains: { [type: string]: CrushNode[] } = {};
+ failureDomainKeys: string[] = [];
+ devices: string[] = [];
+ deviceCount = 0;
+
+ static searchFailureDomains(
+ nodes: CrushNode[],
+ s: string
+ ): { [failureDomain: string]: CrushNode[] } {
+ return this.getFailureDomains(this.search(nodes, s));
+ }
+
+ /**
+ * Filters crush map for a node and it's tree.
+ * The node name as provided in crush rules attribute item_name is supported.
+ * This means that '$name~$deviceType' can be used and will result in a crush map
+ * that only include buckets with the specified device in use as their leaf.
+ */
+ static search(nodes: CrushNode[], s: string): CrushNode[] {
+ const [search, deviceType] = s.split('~'); // Used inside item_name in crush rules
+ const node = nodes.find((n) => ['name', 'id', 'type'].some((attr) => n[attr] === search));
+ if (!node) {
+ return [];
+ }
+ nodes = this.getSubNodes(node, this.createIdTreeFromNodes(nodes));
+ if (deviceType) {
+ nodes = this.filterNodesByDeviceType(nodes, deviceType);
+ }
+ return nodes;
+ }
+
+ static createIdTreeFromNodes(nodes: CrushNode[]): { [id: number]: CrushNode } {
+ const idTree = {};
+ nodes.forEach((node) => {
+ idTree[node.id] = node;
+ });
+ return idTree;
+ }
+
+ static getSubNodes(node: CrushNode, idTree: { [id: number]: CrushNode }): CrushNode[] {
+ let subNodes = [node]; // Includes parent node
+ if (!node.children) {
+ return subNodes;
+ }
+ node.children.forEach((id) => {
+ const childNode = idTree[id];
+ subNodes = subNodes.concat(this.getSubNodes(childNode, idTree));
+ });
+ return subNodes;
+ }
+
+ static filterNodesByDeviceType(nodes: CrushNode[], deviceType: string): any {
+ let doNotInclude = nodes
+ .filter((n) => n.device_class && n.device_class !== deviceType)
+ .map((n) => n.id);
+ let foundNewNode: boolean;
+ let childrenToRemove = doNotInclude;
+
+ // Filters out all unwanted nodes
+ do {
+ foundNewNode = false;
+ nodes = nodes.filter((n) => !doNotInclude.includes(n.id)); // Unwanted nodes
+ // Find nodes where all children were filtered
+ const toRemoveNext: number[] = [];
+ nodes.forEach((n) => {
+ if (n.children && n.children.every((id) => doNotInclude.includes(id))) {
+ toRemoveNext.push(n.id);
+ foundNewNode = true;
+ }
+ });
+ if (foundNewNode) {
+ doNotInclude = toRemoveNext; // Reduces array length
+ childrenToRemove = childrenToRemove.concat(toRemoveNext);
+ }
+ } while (foundNewNode);
+
+ // Removes filtered out children in all left nodes with children
+ nodes = _.cloneDeep(nodes); // Clone objects to not change original objects
+ nodes = nodes.map((n) => {
+ if (!n.children) {
+ return n;
+ }
+ n.children = n.children.filter((id) => !childrenToRemove.includes(id));
+ return n;
+ });
+
+ return nodes;
+ }
+
+ static getFailureDomains(nodes: CrushNode[]): { [failureDomain: string]: CrushNode[] } {
+ const domains = {};
+ nodes.forEach((node) => {
+ const type = node.type;
+ if (!domains[type]) {
+ domains[type] = [];
+ }
+ domains[type].push(node);
+ });
+ return domains;
+ }
+
+ initCrushNodeSelection(
+ nodes: CrushNode[],
+ rootControl: AbstractControl,
+ failureControl: AbstractControl,
+ deviceControl: AbstractControl
+ ) {
+ this.nodes = nodes;
+ this.idTree = CrushNodeSelectionClass.createIdTreeFromNodes(nodes);
+ nodes.forEach((node) => {
+ this.idTree[node.id] = node;
+ });
+ this.buckets = _.sortBy(
+ nodes.filter((n) => n.children),
+ 'name'
+ );
+ this.controls = {
+ root: rootControl,
+ failure: failureControl,
+ device: deviceControl
+ };
+ this.preSelectRoot();
+ this.controls.root.valueChanges.subscribe(() => this.onRootChange());
+ this.controls.failure.valueChanges.subscribe(() => this.onFailureDomainChange());
+ this.controls.device.valueChanges.subscribe(() => this.onDeviceChange());
+ }
+
+ private preSelectRoot() {
+ const rootNode = this.nodes.find((node) => node.type === 'root');
+ this.silentSet(this.controls.root, rootNode);
+ this.onRootChange();
+ }
+
+ private silentSet(control: AbstractControl, value: any) {
+ control.setValue(value, { emitEvent: false });
+ }
+
+ private onRootChange() {
+ const nodes = CrushNodeSelectionClass.getSubNodes(this.controls.root.value, this.idTree);
+ const domains = CrushNodeSelectionClass.getFailureDomains(nodes);
+ Object.keys(domains).forEach((type) => {
+ if (domains[type].length <= 1) {
+ delete domains[type];
+ }
+ });
+ this.failureDomains = domains;
+ this.failureDomainKeys = Object.keys(domains).sort();
+ this.updateFailureDomain();
+ }
+
+ private updateFailureDomain() {
+ let failureDomain = this.getIncludedCustomValue(
+ this.controls.failure,
+ Object.keys(this.failureDomains)
+ );
+ if (failureDomain === '') {
+ failureDomain = this.setMostCommonDomain(this.controls.failure);
+ }
+ this.updateDevices(failureDomain);
+ }
+
+ private getIncludedCustomValue(control: AbstractControl, includedIn: string[]) {
+ return control.dirty && includedIn.includes(control.value) ? control.value : '';
+ }
+
+ private setMostCommonDomain(failureControl: AbstractControl): string {
+ let winner = { n: 0, type: '' };
+ Object.keys(this.failureDomains).forEach((type) => {
+ const n = this.failureDomains[type].length;
+ if (winner.n < n) {
+ winner = { n, type };
+ }
+ });
+ this.silentSet(failureControl, winner.type);
+ return winner.type;
+ }
+
+ private onFailureDomainChange() {
+ this.updateDevices();
+ }
+
+ private updateDevices(failureDomain: string = this.controls.failure.value) {
+ const subNodes = _.flatten(
+ this.failureDomains[failureDomain].map((node) =>
+ CrushNodeSelectionClass.getSubNodes(node, this.idTree)
+ )
+ );
+ this.allDevices = subNodes.filter((n) => n.device_class).map((n) => n.device_class);
+ this.devices = _.uniq(this.allDevices).sort();
+ const device =
+ this.devices.length === 1
+ ? this.devices[0]
+ : this.getIncludedCustomValue(this.controls.device, this.devices);
+ this.silentSet(this.controls.device, device);
+ this.onDeviceChange(device);
+ }
+
+ private onDeviceChange(deviceType: string = this.controls.device.value) {
+ this.deviceCount =
+ deviceType === ''
+ ? this.allDevices.length
+ : this.allDevices.filter((type) => type === deviceType).length;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts
new file mode 100644
index 000000000..e5caef761
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts
@@ -0,0 +1,5 @@
+export class CssHelper {
+ propertyValue(propertyName: string): string {
+ return getComputedStyle(document.body).getPropertyValue(`--${propertyName}`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts
new file mode 100644
index 000000000..2eaeeb35e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts
@@ -0,0 +1,29 @@
+import { NgZone } from '@angular/core';
+
+import { TableStatus } from './table-status';
+
+export class ListWithDetails {
+ expandedRow: any;
+ staleTimeout: number;
+ tableStatus: TableStatus;
+
+ constructor(protected ngZone?: NgZone) {}
+
+ setExpandedRow(expandedRow: any) {
+ this.expandedRow = expandedRow;
+ }
+
+ setTableRefreshTimeout() {
+ clearTimeout(this.staleTimeout);
+ this.ngZone.runOutsideAngular(() => {
+ this.staleTimeout = window.setTimeout(() => {
+ this.ngZone.run(() => {
+ this.tableStatus = new TableStatus(
+ 'warning',
+ $localize`The user list data might be stale. If needed, you can manually reload it.`
+ );
+ });
+ }, 10000);
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts
new file mode 100644
index 000000000..cff2ec33a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts
@@ -0,0 +1,40 @@
+import { ViewCacheStatus } from '../enum/view-cache-status.enum';
+import { TableStatusViewCache } from './table-status-view-cache';
+
+describe('TableStatusViewCache', () => {
+ it('should create an instance', () => {
+ const ts = new TableStatusViewCache();
+ expect(ts).toBeTruthy();
+ expect(ts).toEqual({ msg: '', type: 'light' });
+ });
+
+ it('should create a ValueStale instance', () => {
+ let ts = new TableStatusViewCache(ViewCacheStatus.ValueStale);
+ expect(ts).toEqual({ type: 'warning', msg: 'Displaying previously cached data.' });
+
+ ts = new TableStatusViewCache(ViewCacheStatus.ValueStale, 'foo bar');
+ expect(ts).toEqual({ type: 'warning', msg: 'Displaying previously cached data for foo bar.' });
+ });
+
+ it('should create a ValueNone instance', () => {
+ let ts = new TableStatusViewCache(ViewCacheStatus.ValueNone);
+ expect(ts).toEqual({ type: 'info', msg: 'Retrieving data. Please wait...' });
+
+ ts = new TableStatusViewCache(ViewCacheStatus.ValueNone, 'foo bar');
+ expect(ts).toEqual({ type: 'info', msg: 'Retrieving data for foo bar. Please wait...' });
+ });
+
+ it('should create a ValueException instance', () => {
+ let ts = new TableStatusViewCache(ViewCacheStatus.ValueException);
+ expect(ts).toEqual({
+ type: 'danger',
+ msg: 'Could not load data. Please check the cluster health.'
+ });
+
+ ts = new TableStatusViewCache(ViewCacheStatus.ValueException, 'foo bar');
+ expect(ts).toEqual({
+ type: 'danger',
+ msg: 'Could not load data for foo bar. Please check the cluster health.'
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts
new file mode 100644
index 000000000..91c53a0aa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts
@@ -0,0 +1,37 @@
+import { ViewCacheStatus } from '../enum/view-cache-status.enum';
+import { TableStatus } from './table-status';
+
+export class TableStatusViewCache extends TableStatus {
+ constructor(status: ViewCacheStatus = ViewCacheStatus.ValueOk, statusFor: string = '') {
+ super();
+
+ switch (status) {
+ case ViewCacheStatus.ValueOk:
+ this.type = 'light';
+ this.msg = '';
+ break;
+ case ViewCacheStatus.ValueNone:
+ this.type = 'info';
+ this.msg =
+ (statusFor ? $localize`Retrieving data for ${statusFor}.` : $localize`Retrieving data.`) +
+ ' ' +
+ $localize`Please wait...`;
+ break;
+ case ViewCacheStatus.ValueStale:
+ this.type = 'warning';
+ this.msg = statusFor
+ ? $localize`Displaying previously cached data for ${statusFor}.`
+ : $localize`Displaying previously cached data.`;
+ break;
+ case ViewCacheStatus.ValueException:
+ this.type = 'danger';
+ this.msg =
+ (statusFor
+ ? $localize`Could not load data for ${statusFor}.`
+ : $localize`Could not load data.`) +
+ ' ' +
+ $localize`Please check the cluster health.`;
+ break;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts
new file mode 100644
index 000000000..7fa7ba1a4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts
@@ -0,0 +1,15 @@
+import { TableStatus } from './table-status';
+
+describe('TableStatus', () => {
+ it('should create an instance', () => {
+ const ts = new TableStatus();
+ expect(ts).toBeTruthy();
+ expect(ts).toEqual({ msg: '', type: 'light' });
+ });
+
+ it('should create with parameters', () => {
+ const ts = new TableStatus('danger', 'foo');
+ expect(ts).toBeTruthy();
+ expect(ts).toEqual({ msg: 'foo', type: 'danger' });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts
new file mode 100644
index 000000000..fa9be80fe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts
@@ -0,0 +1,3 @@
+export class TableStatus {
+ constructor(public type: 'info' | 'warning' | 'danger' | 'light' = 'light', public msg = '') {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
new file mode 100644
index 000000000..be8096427
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
@@ -0,0 +1,42 @@
+<ngb-alert type="{{ bootstrapClass }}"
+ [dismissible]="dismissible"
+ (closed)="onClose()">
+ <table>
+ <ng-container *ngIf="size === 'normal'; else slim">
+ <tr>
+ <td *ngIf="showIcon"
+ rowspan="2"
+ class="alert-panel-icon">
+ <i [ngClass]="[icons.large3x]"
+ class="alert-{{ bootstrapClass }} {{ typeIcon }}"
+ aria-hidden="true"></i>
+ </td>
+ <td *ngIf="showTitle"
+ class="alert-panel-title">{{ title }}</td>
+ </tr>
+ <tr>
+ <td class="alert-panel-text">
+ <ng-container *ngTemplateOutlet="content"></ng-container>
+ </td>
+ </tr>
+ </ng-container>
+ <ng-template #slim>
+ <tr>
+ <td *ngIf="showIcon"
+ class="alert-panel-icon">
+ <i class="alert-{{ bootstrapClass }} {{ typeIcon }}"
+ aria-hidden="true"></i>
+ </td>
+ <td *ngIf="showTitle"
+ class="alert-panel-title">{{ title }}</td>
+ <td class="alert-panel-text">
+ <ng-container *ngTemplateOutlet="content"></ng-container>
+ </td>
+ </tr>
+ </ng-template>
+ </table>
+</ngb-alert>
+
+<ng-template #content>
+ <ng-content></ng-content>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss
new file mode 100644
index 000000000..6b89d6d3e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss
@@ -0,0 +1,12 @@
+.alert-panel-icon {
+ padding-right: 0.5em;
+ vertical-align: top;
+}
+
+.alert-panel-title {
+ font-weight: bold;
+}
+
+.alert {
+ margin-bottom: 0;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts
new file mode 100644
index 000000000..4b1f3f7cc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts
@@ -0,0 +1,26 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AlertPanelComponent } from './alert-panel.component';
+
+describe('AlertPanelComponent', () => {
+ let component: AlertPanelComponent;
+ let fixture: ComponentFixture<AlertPanelComponent>;
+
+ configureTestBed({
+ declarations: [AlertPanelComponent],
+ imports: [NgbAlertModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AlertPanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
new file mode 100644
index 000000000..51088840e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
@@ -0,0 +1,70 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-alert-panel',
+ templateUrl: './alert-panel.component.html',
+ styleUrls: ['./alert-panel.component.scss']
+})
+export class AlertPanelComponent implements OnInit {
+ @Input()
+ title = '';
+ @Input()
+ bootstrapClass = '';
+ @Input()
+ type: 'warning' | 'error' | 'info' | 'success' | 'danger';
+ @Input()
+ typeIcon: Icons | string;
+ @Input()
+ size: 'slim' | 'normal' = 'normal';
+ @Input()
+ showIcon = true;
+ @Input()
+ showTitle = true;
+ @Input()
+ dismissible = false;
+
+ /**
+ * The event that is triggered when the close button (x) has been
+ * pressed.
+ */
+ @Output()
+ dismissed = new EventEmitter();
+
+ icons = Icons;
+
+ ngOnInit() {
+ switch (this.type) {
+ case 'warning':
+ this.title = this.title || $localize`Warning`;
+ this.typeIcon = this.typeIcon || Icons.warning;
+ this.bootstrapClass = this.bootstrapClass || 'warning';
+ break;
+ case 'error':
+ this.title = this.title || $localize`Error`;
+ this.typeIcon = this.typeIcon || Icons.destroyCircle;
+ this.bootstrapClass = this.bootstrapClass || 'danger';
+ break;
+ case 'info':
+ this.title = this.title || $localize`Information`;
+ this.typeIcon = this.typeIcon || Icons.infoCircle;
+ this.bootstrapClass = this.bootstrapClass || 'info';
+ break;
+ case 'success':
+ this.title = this.title || $localize`Success`;
+ this.typeIcon = this.typeIcon || Icons.check;
+ this.bootstrapClass = this.bootstrapClass || 'success';
+ break;
+ case 'danger':
+ this.title = this.title || $localize`Danger`;
+ this.typeIcon = this.typeIcon || Icons.warning;
+ this.bootstrapClass = this.bootstrapClass || 'danger';
+ break;
+ }
+ }
+
+ onClose(): void {
+ this.dismissed.emit();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html
new file mode 100644
index 000000000..a9090aaf2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html
@@ -0,0 +1,5 @@
+<button class="btn btn-light tc_backButton"
+ (click)="back()"
+ type="button">
+ {{ name }}
+</button>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts
new file mode 100644
index 000000000..d3120a283
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { BackButtonComponent } from './back-button.component';
+
+describe('BackButtonComponent', () => {
+ let component: BackButtonComponent;
+ let fixture: ComponentFixture<BackButtonComponent>;
+
+ configureTestBed({
+ imports: [RouterTestingModule],
+ declarations: [BackButtonComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BackButtonComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts
new file mode 100644
index 000000000..a578f0394
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts
@@ -0,0 +1,24 @@
+import { Location } from '@angular/common';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+
+@Component({
+ selector: 'cd-back-button',
+ templateUrl: './back-button.component.html',
+ styleUrls: ['./back-button.component.scss']
+})
+export class BackButtonComponent {
+ @Output() backAction = new EventEmitter();
+ @Input() name: string = this.actionLabels.CANCEL;
+
+ constructor(private location: Location, private actionLabels: ActionLabelsI18n) {}
+
+ back() {
+ if (this.backAction.observers.length === 0) {
+ this.location.back();
+ } else {
+ this.backAction.emit();
+ }
+ }
+}
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
new file mode 100644
index 000000000..a281bf859
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
@@ -0,0 +1,132 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import {
+ NgbAlertModule,
+ NgbDatepickerModule,
+ NgbDropdownModule,
+ NgbPopoverModule,
+ NgbProgressbarModule,
+ NgbTimepickerModule,
+ NgbTooltipModule
+} from '@ng-bootstrap/ng-bootstrap';
+import { ClickOutsideModule } from 'ng-click-outside';
+import { ChartsModule } from 'ng2-charts';
+import { SimplebarAngularModule } from 'simplebar-angular';
+
+import { MotdComponent } from '~/app/shared/components/motd/motd.component';
+import { DirectivesModule } from '../directives/directives.module';
+import { PipesModule } from '../pipes/pipes.module';
+import { AlertPanelComponent } from './alert-panel/alert-panel.component';
+import { BackButtonComponent } from './back-button/back-button.component';
+import { ConfigOptionComponent } from './config-option/config-option.component';
+import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component';
+import { Copy2ClipboardButtonComponent } from './copy2clipboard-button/copy2clipboard-button.component';
+import { CriticalConfirmationModalComponent } from './critical-confirmation-modal/critical-confirmation-modal.component';
+import { CustomLoginBannerComponent } from './custom-login-banner/custom-login-banner.component';
+import { DateTimePickerComponent } from './date-time-picker/date-time-picker.component';
+import { DocComponent } from './doc/doc.component';
+import { DownloadButtonComponent } from './download-button/download-button.component';
+import { FormButtonPanelComponent } from './form-button-panel/form-button-panel.component';
+import { FormModalComponent } from './form-modal/form-modal.component';
+import { GrafanaComponent } from './grafana/grafana.component';
+import { HelperComponent } from './helper/helper.component';
+import { LanguageSelectorComponent } from './language-selector/language-selector.component';
+import { LoadingPanelComponent } from './loading-panel/loading-panel.component';
+import { ModalComponent } from './modal/modal.component';
+import { NotificationsSidebarComponent } from './notifications-sidebar/notifications-sidebar.component';
+import { OrchestratorDocPanelComponent } from './orchestrator-doc-panel/orchestrator-doc-panel.component';
+import { PwdExpirationNotificationComponent } from './pwd-expiration-notification/pwd-expiration-notification.component';
+import { RefreshSelectorComponent } from './refresh-selector/refresh-selector.component';
+import { SelectBadgesComponent } from './select-badges/select-badges.component';
+import { SelectComponent } from './select/select.component';
+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: [
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbAlertModule,
+ NgbPopoverModule,
+ NgbProgressbarModule,
+ NgbTooltipModule,
+ ChartsModule,
+ ReactiveFormsModule,
+ PipesModule,
+ DirectivesModule,
+ NgbDropdownModule,
+ ClickOutsideModule,
+ SimplebarAngularModule,
+ RouterModule,
+ NgbDatepickerModule,
+ NgbTimepickerModule
+ ],
+ declarations: [
+ SparklineComponent,
+ HelperComponent,
+ SelectBadgesComponent,
+ SubmitButtonComponent,
+ UsageBarComponent,
+ LoadingPanelComponent,
+ ModalComponent,
+ NotificationsSidebarComponent,
+ CriticalConfirmationModalComponent,
+ ConfirmationModalComponent,
+ LanguageSelectorComponent,
+ GrafanaComponent,
+ SelectComponent,
+ BackButtonComponent,
+ RefreshSelectorComponent,
+ ConfigOptionComponent,
+ AlertPanelComponent,
+ FormModalComponent,
+ PwdExpirationNotificationComponent,
+ TelemetryNotificationComponent,
+ OrchestratorDocPanelComponent,
+ DateTimePickerComponent,
+ DocComponent,
+ Copy2ClipboardButtonComponent,
+ DownloadButtonComponent,
+ FormButtonPanelComponent,
+ MotdComponent,
+ WizardComponent,
+ CustomLoginBannerComponent
+ ],
+ providers: [],
+ exports: [
+ SparklineComponent,
+ HelperComponent,
+ SelectBadgesComponent,
+ SubmitButtonComponent,
+ BackButtonComponent,
+ LoadingPanelComponent,
+ UsageBarComponent,
+ ModalComponent,
+ NotificationsSidebarComponent,
+ LanguageSelectorComponent,
+ GrafanaComponent,
+ SelectComponent,
+ RefreshSelectorComponent,
+ ConfigOptionComponent,
+ AlertPanelComponent,
+ PwdExpirationNotificationComponent,
+ TelemetryNotificationComponent,
+ OrchestratorDocPanelComponent,
+ DateTimePickerComponent,
+ DocComponent,
+ Copy2ClipboardButtonComponent,
+ DownloadButtonComponent,
+ FormButtonPanelComponent,
+ MotdComponent,
+ WizardComponent,
+ CustomLoginBannerComponent
+ ]
+})
+export class ComponentsModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html
new file mode 100644
index 000000000..0b0f87957
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html
@@ -0,0 +1,77 @@
+<div [formGroup]="optionsFormGroup">
+ <div *ngFor="let option of options; let last = last">
+ <div class="form-group row pt-2"
+ *ngIf="option.type === 'bool'">
+ <label class="cd-col-form-label"
+ [for]="option.name">
+ <b>{{ option.text }}</b>
+ <br>
+ <span class="text-muted">
+ {{ option.desc }}
+ <cd-helper *ngIf="option.long_desc">
+ {{ option.long_desc }}</cd-helper>
+ </span>
+ </label>
+
+ <div class="cd-col-form-input">
+ <div class="custom-control custom-checkbox">
+ <input class="custom-control-input"
+ type="checkbox"
+ [id]="option.name"
+ [formControlName]="option.name">
+ <label class="custom-control-label"
+ [for]="option.name"></label>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group row pt-2"
+ *ngIf="option.type !== 'bool'">
+ <label class="cd-col-form-label"
+ [for]="option.name">{{ option.text }}
+ <br>
+ <span class="text-muted">
+ {{ option.desc }}
+ <cd-helper *ngIf="option.long_desc">
+ {{ option.long_desc }}</cd-helper>
+ </span>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input class="form-control"
+ [type]="option.additionalTypeInfo.inputType"
+ [id]="option.name"
+ [placeholder]="option.additionalTypeInfo.humanReadable"
+ [formControlName]="option.name"
+ [step]="getStep(option.type, optionsForm.getValue(option.name))">
+ <div class="input-group-append"
+ *ngIf="optionsFormShowReset">
+ <button class="btn btn-light"
+ type="button"
+ data-toggle="button"
+ title="Remove the custom configuration value. The default configuration will be inherited and used instead."
+ (click)="resetValue(option.name)"
+ i18n-title>
+ <i [ngClass]="[icons.erase]"
+ aria-hidden="true"></i>
+ </button>
+ </div>
+ </div>
+ <span class="invalid-feedback"
+ *ngIf="optionsForm.showError(option.name, optionsFormDir, 'pattern')">
+ {{ option.additionalTypeInfo.patternHelpText }}</span>
+ <span class="invalid-feedback"
+ *ngIf="optionsForm.showError(option.name, optionsFormDir, 'invalidUuid')">
+ {{ option.additionalTypeInfo.patternHelpText }}</span>
+ <span class="invalid-feedback"
+ *ngIf="optionsForm.showError(option.name, optionsFormDir, 'max')"
+ i18n>The entered value is too high! It must not be greater than {{ option.maxValue }}.</span>
+ <span class="invalid-feedback"
+ *ngIf="optionsForm.showError(option.name, optionsFormDir, 'min')"
+ i18n>The entered value is too low! It must not be lower than {{ option.minValue }}.</span>
+ </div>
+ </div>
+ <hr *ngIf="!last"
+ class="my-2">
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss
new file mode 100644
index 000000000..e35c2e37b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss
@@ -0,0 +1,10 @@
+.custom-checkbox {
+ label,
+ input {
+ cursor: pointer;
+ }
+}
+
+.col-form-label {
+ text-align: left;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts
new file mode 100644
index 000000000..200a27615
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts
@@ -0,0 +1,295 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { of as observableOf } from 'rxjs';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HelperComponent } from '../helper/helper.component';
+import { ConfigOptionComponent } from './config-option.component';
+
+describe('ConfigOptionComponent', () => {
+ let component: ConfigOptionComponent;
+ let fixture: ComponentFixture<ConfigOptionComponent>;
+ let configurationService: ConfigurationService;
+ let oNames: Array<string>;
+
+ configureTestBed({
+ declarations: [ConfigOptionComponent, HelperComponent],
+ imports: [NgbPopoverModule, ReactiveFormsModule, HttpClientTestingModule],
+ providers: [ConfigurationService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ConfigOptionComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ configurationService = TestBed.inject(ConfigurationService);
+
+ const configOptions: Record<string, any> = [
+ {
+ name: 'osd_scrub_auto_repair_num_errors',
+ type: 'uint',
+ level: 'advanced',
+ desc: 'Maximum number of detected errors to automatically repair',
+ long_desc: '',
+ default: 5,
+ daemon_default: '',
+ tags: [],
+ services: [],
+ see_also: ['osd_scrub_auto_repair'],
+ min: '',
+ max: '',
+ can_update_at_runtime: true,
+ flags: []
+ },
+ {
+ name: 'osd_debug_deep_scrub_sleep',
+ type: 'float',
+ level: 'dev',
+ desc:
+ 'Inject an expensive sleep during deep scrub IO to make it easier to induce preemption',
+ long_desc: '',
+ default: 0,
+ daemon_default: '',
+ tags: [],
+ services: [],
+ see_also: [],
+ min: '',
+ max: '',
+ can_update_at_runtime: true,
+ flags: []
+ },
+ {
+ name: 'osd_heartbeat_interval',
+ type: 'int',
+ level: 'advanced',
+ desc: 'Interval (in seconds) between peer pings',
+ long_desc: '',
+ default: 6,
+ daemon_default: '',
+ tags: [],
+ services: [],
+ see_also: [],
+ min: 1,
+ max: 86400,
+ can_update_at_runtime: true,
+ flags: [],
+ value: [
+ {
+ section: 'osd',
+ value: 6
+ }
+ ]
+ },
+ {
+ name: 'bluestore_compression_algorithm',
+ type: 'str',
+ level: 'advanced',
+ desc: 'Default compression algorithm to use when writing object data',
+ long_desc:
+ 'This controls the default compressor to use (if any) if the ' +
+ 'per-pool property is not set. Note that zstd is *not* recommended for ' +
+ 'bluestore due to high CPU overhead when compressing small amounts of data.',
+ default: 'snappy',
+ daemon_default: '',
+ tags: [],
+ services: [],
+ see_also: [],
+ enum_values: ['', 'snappy', 'zlib', 'zstd', 'lz4'],
+ min: '',
+ max: '',
+ can_update_at_runtime: true,
+ flags: ['runtime']
+ },
+ {
+ name: 'rbd_discard_on_zeroed_write_same',
+ type: 'bool',
+ level: 'advanced',
+ desc: 'discard data on zeroed write same instead of writing zero',
+ long_desc: '',
+ default: true,
+ daemon_default: '',
+ tags: [],
+ services: ['rbd'],
+ see_also: [],
+ min: '',
+ max: '',
+ can_update_at_runtime: true,
+ flags: []
+ },
+ {
+ name: 'rbd_journal_max_payload_bytes',
+ type: 'size',
+ level: 'advanced',
+ desc: 'maximum journal payload size before splitting',
+ long_desc: '',
+ daemon_default: '',
+ tags: [],
+ services: ['rbd'],
+ see_also: [],
+ min: '',
+ max: '',
+ can_update_at_runtime: true,
+ flags: [],
+ default: '16384'
+ },
+ {
+ name: 'cluster_addr',
+ type: 'addr',
+ level: 'basic',
+ desc: 'cluster-facing address to bind to',
+ long_desc: '',
+ daemon_default: '',
+ tags: ['network'],
+ services: ['osd'],
+ see_also: [],
+ min: '',
+ max: '',
+ can_update_at_runtime: false,
+ flags: [],
+ default: '-'
+ },
+ {
+ name: 'fsid',
+ type: 'uuid',
+ level: 'basic',
+ desc: 'cluster fsid (uuid)',
+ long_desc: '',
+ daemon_default: '',
+ tags: ['service'],
+ services: ['common'],
+ see_also: [],
+ min: '',
+ max: '',
+ can_update_at_runtime: false,
+ flags: ['no_mon_update'],
+ default: '00000000-0000-0000-0000-000000000000'
+ },
+ {
+ name: 'mgr_tick_period',
+ type: 'secs',
+ level: 'advanced',
+ desc: 'Period in seconds of beacon messages to monitor',
+ long_desc: '',
+ daemon_default: '',
+ tags: [],
+ services: ['mgr'],
+ see_also: [],
+ min: '',
+ max: '',
+ can_update_at_runtime: true,
+ flags: [],
+ default: '2'
+ }
+ ];
+
+ spyOn(configurationService, 'filter').and.returnValue(observableOf(configOptions));
+ oNames = _.map(configOptions, 'name');
+ component.optionNames = oNames;
+ component.optionsForm = new CdFormGroup({});
+ component.optionsFormGroupName = 'testFormGroupName';
+ component.ngOnInit();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('optionNameToText', () => {
+ it('should format config option names correctly', () => {
+ const configOptionNames = {
+ osd_scrub_auto_repair_num_errors: 'Scrub Auto Repair Num Errors',
+ osd_debug_deep_scrub_sleep: 'Debug Deep Scrub Sleep',
+ osd_heartbeat_interval: 'Heartbeat Interval',
+ bluestore_compression_algorithm: 'Bluestore Compression Algorithm',
+ rbd_discard_on_zeroed_write_same: 'Rbd Discard On Zeroed Write Same',
+ rbd_journal_max_payload_bytes: 'Rbd Journal Max Payload Bytes',
+ cluster_addr: 'Cluster Addr',
+ fsid: 'Fsid',
+ mgr_tick_period: 'Tick Period'
+ };
+
+ component.options.forEach((option) => {
+ expect(option.text).toEqual(configOptionNames[option.name]);
+ });
+ });
+ });
+
+ describe('createForm', () => {
+ it('should set the optionsFormGroupName correctly', () => {
+ expect(component.optionsFormGroupName).toEqual('testFormGroupName');
+ });
+
+ it('should create a FormControl for every config option', () => {
+ component.options.forEach((option) => {
+ expect(Object.keys(component.optionsFormGroup.controls)).toContain(option.name);
+ });
+ });
+ });
+
+ describe('loadStorageData', () => {
+ it('should create a list of config options by names', () => {
+ expect(component.options.length).toEqual(9);
+
+ component.options.forEach((option) => {
+ expect(oNames).toContain(option.name);
+ });
+ });
+
+ it('should add all needed attributes to every config option', () => {
+ component.options.forEach((option) => {
+ const optionKeys = Object.keys(option);
+ expect(optionKeys).toContain('text');
+ expect(optionKeys).toContain('additionalTypeInfo');
+ expect(optionKeys).toContain('value');
+
+ if (option.type !== 'bool' && option.type !== 'str') {
+ expect(optionKeys).toContain('patternHelpText');
+ }
+
+ if (option.name === 'osd_heartbeat_interval') {
+ expect(optionKeys).toContain('maxValue');
+ expect(optionKeys).toContain('minValue');
+ }
+ });
+ });
+
+ it('should set minValue and maxValue correctly', () => {
+ component.options.forEach((option) => {
+ if (option.name === 'osd_heartbeat_interval') {
+ expect(option.minValue).toEqual(1);
+ expect(option.maxValue).toEqual(86400);
+ }
+ });
+ });
+
+ it('should set the value attribute correctly', () => {
+ component.options.forEach((option) => {
+ if (option.name === 'osd_heartbeat_interval') {
+ const value = option.value;
+ expect(value).toBeDefined();
+ expect(value).toEqual({ section: 'osd', value: 6 });
+ } else {
+ expect(option.value).toBeUndefined();
+ }
+ });
+ });
+
+ it('should set the FormControl value correctly', () => {
+ component.options.forEach((option) => {
+ const value = component.optionsFormGroup.getValue(option.name);
+ if (option.name === 'osd_heartbeat_interval') {
+ expect(value).toBeDefined();
+ expect(value).toEqual(6);
+ } else {
+ expect(value).toBeNull();
+ }
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts
new file mode 100644
index 000000000..2ac8e569a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts
@@ -0,0 +1,120 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { FormControl, NgForm } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { ConfigOptionTypes } from './config-option.types';
+
+@Component({
+ selector: 'cd-config-option',
+ templateUrl: './config-option.component.html',
+ styleUrls: ['./config-option.component.scss']
+})
+export class ConfigOptionComponent implements OnInit {
+ @Input()
+ optionNames: Array<string> = [];
+ @Input()
+ optionsForm: CdFormGroup = new CdFormGroup({});
+ @Input()
+ optionsFormDir: NgForm = new NgForm([], []);
+ @Input()
+ optionsFormGroupName = '';
+ @Input()
+ optionsFormShowReset = true;
+
+ icons = Icons;
+ options: Array<any> = [];
+ optionsFormGroup: CdFormGroup = new CdFormGroup({});
+
+ constructor(private configService: ConfigurationService) {}
+
+ private static optionNameToText(optionName: string): string {
+ const sections = ['mon', 'mgr', 'osd', 'mds', 'client'];
+ return optionName
+ .split('_')
+ .filter((c, index) => index !== 0 || !sections.includes(c))
+ .map((c) => c.charAt(0).toUpperCase() + c.substring(1))
+ .join(' ');
+ }
+
+ ngOnInit() {
+ this.createForm();
+ this.loadStoredData();
+ }
+
+ private createForm() {
+ this.optionsForm.addControl(this.optionsFormGroupName, this.optionsFormGroup);
+ this.optionNames.forEach((optionName) => {
+ this.optionsFormGroup.addControl(optionName, new FormControl(null));
+ });
+ }
+
+ getStep(type: string, value: any): number | undefined {
+ return ConfigOptionTypes.getTypeStep(type, value);
+ }
+
+ private loadStoredData() {
+ this.configService.filter(this.optionNames).subscribe((data: any) => {
+ this.options = data.map((configOption: any) => {
+ const formControl = this.optionsForm.get(configOption.name);
+ const typeValidators = ConfigOptionTypes.getTypeValidators(configOption);
+ configOption.additionalTypeInfo = ConfigOptionTypes.getType(configOption.type);
+
+ // Set general information and value
+ configOption.text = ConfigOptionComponent.optionNameToText(configOption.name);
+ configOption.value = _.find(configOption.value, (p) => {
+ return p.section === 'osd'; // TODO: Can handle any other section
+ });
+ if (configOption.value) {
+ if (configOption.additionalTypeInfo.name === 'bool') {
+ formControl.setValue(configOption.value.value === 'true');
+ } else {
+ formControl.setValue(configOption.value.value);
+ }
+ }
+
+ // Set type information and validators
+ if (typeValidators) {
+ configOption.patternHelpText = typeValidators.patternHelpText;
+ if ('max' in typeValidators && typeValidators.max !== '') {
+ configOption.maxValue = typeValidators.max;
+ }
+ if ('min' in typeValidators && typeValidators.min !== '') {
+ configOption.minValue = typeValidators.min;
+ }
+ formControl.setValidators(typeValidators.validators);
+ }
+
+ return configOption;
+ });
+ });
+ }
+
+ saveValues() {
+ const options = {};
+ this.optionNames.forEach((optionName) => {
+ const optionValue = this.optionsForm.getValue(optionName);
+ if (optionValue !== null && optionValue !== '') {
+ options[optionName] = {
+ section: 'osd', // TODO: Can handle any other section
+ value: optionValue
+ };
+ }
+ });
+
+ return this.configService.bulkCreate({ options: options });
+ }
+
+ resetValue(optionName: string) {
+ this.configService.delete(optionName, 'osd').subscribe(
+ // TODO: Can handle any other section
+ () => {
+ const formControl = this.optionsForm.get(optionName);
+ formControl.reset();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts
new file mode 100644
index 000000000..d3ebc5f37
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts
@@ -0,0 +1,12 @@
+export class ConfigFormModel {
+ name: string;
+ desc: string;
+ long_desc: string;
+ type: string;
+ value: Array<any>;
+ default: any;
+ daemon_default: any;
+ min: any;
+ max: any;
+ services: Array<string>;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts
new file mode 100644
index 000000000..8c34111b9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts
@@ -0,0 +1,272 @@
+import { ConfigFormModel } from './config-option.model';
+import { ConfigOptionTypes } from './config-option.types';
+
+describe('ConfigOptionTypes', () => {
+ describe('getType', () => {
+ it('should return uint type', () => {
+ const ret = ConfigOptionTypes.getType('uint');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('uint');
+ expect(ret.inputType).toBe('number');
+ expect(ret.humanReadable).toBe('Unsigned integer value');
+ expect(ret.defaultMin).toBe(0);
+ expect(ret.patternHelpText).toBe('The entered value needs to be an unsigned number.');
+ expect(ret.isNumberType).toBe(true);
+ expect(ret.allowsNegative).toBe(false);
+ });
+
+ it('should return int type', () => {
+ const ret = ConfigOptionTypes.getType('int');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('int');
+ expect(ret.inputType).toBe('number');
+ expect(ret.humanReadable).toBe('Integer value');
+ expect(ret.defaultMin).toBeUndefined();
+ expect(ret.patternHelpText).toBe('The entered value needs to be a number.');
+ expect(ret.isNumberType).toBe(true);
+ expect(ret.allowsNegative).toBe(true);
+ });
+
+ it('should return size type', () => {
+ const ret = ConfigOptionTypes.getType('size');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('size');
+ expect(ret.inputType).toBe('number');
+ expect(ret.humanReadable).toBe('Unsigned integer value (>=16bit)');
+ expect(ret.defaultMin).toBe(0);
+ expect(ret.patternHelpText).toBe('The entered value needs to be a unsigned number.');
+ expect(ret.isNumberType).toBe(true);
+ expect(ret.allowsNegative).toBe(false);
+ });
+
+ it('should return secs type', () => {
+ const ret = ConfigOptionTypes.getType('secs');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('secs');
+ expect(ret.inputType).toBe('number');
+ expect(ret.humanReadable).toBe('Number of seconds');
+ expect(ret.defaultMin).toBe(1);
+ expect(ret.patternHelpText).toBe('The entered value needs to be a number >= 1.');
+ expect(ret.isNumberType).toBe(true);
+ expect(ret.allowsNegative).toBe(false);
+ });
+
+ it('should return float type', () => {
+ const ret = ConfigOptionTypes.getType('float');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('float');
+ expect(ret.inputType).toBe('number');
+ expect(ret.humanReadable).toBe('Double value');
+ expect(ret.defaultMin).toBeUndefined();
+ expect(ret.patternHelpText).toBe('The entered value needs to be a number or decimal.');
+ expect(ret.isNumberType).toBe(true);
+ expect(ret.allowsNegative).toBe(true);
+ });
+
+ it('should return str type', () => {
+ const ret = ConfigOptionTypes.getType('str');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('str');
+ expect(ret.inputType).toBe('text');
+ expect(ret.humanReadable).toBe('Text');
+ expect(ret.defaultMin).toBeUndefined();
+ expect(ret.patternHelpText).toBeUndefined();
+ expect(ret.isNumberType).toBe(false);
+ expect(ret.allowsNegative).toBeUndefined();
+ });
+
+ it('should return addr type', () => {
+ const ret = ConfigOptionTypes.getType('addr');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('addr');
+ expect(ret.inputType).toBe('text');
+ expect(ret.humanReadable).toBe('IPv4 or IPv6 address');
+ expect(ret.defaultMin).toBeUndefined();
+ expect(ret.patternHelpText).toBe('The entered value needs to be a valid IP address.');
+ expect(ret.isNumberType).toBe(false);
+ expect(ret.allowsNegative).toBeUndefined();
+ });
+
+ it('should return uuid type', () => {
+ const ret = ConfigOptionTypes.getType('uuid');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('uuid');
+ expect(ret.inputType).toBe('text');
+ expect(ret.humanReadable).toBe('UUID');
+ expect(ret.defaultMin).toBeUndefined();
+ expect(ret.patternHelpText).toBe(
+ 'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8'
+ );
+ expect(ret.isNumberType).toBe(false);
+ expect(ret.allowsNegative).toBeUndefined();
+ });
+
+ it('should return bool type', () => {
+ const ret = ConfigOptionTypes.getType('bool');
+ expect(ret).toBeTruthy();
+ expect(ret.name).toBe('bool');
+ expect(ret.inputType).toBe('checkbox');
+ expect(ret.humanReadable).toBe('Boolean value');
+ expect(ret.defaultMin).toBeUndefined();
+ expect(ret.patternHelpText).toBeUndefined();
+ expect(ret.isNumberType).toBe(false);
+ expect(ret.allowsNegative).toBeUndefined();
+ });
+
+ it('should throw an error for unknown type', () => {
+ expect(() => ConfigOptionTypes.getType('unknown')).toThrowError(
+ 'Found unknown type "unknown" for config option.'
+ );
+ });
+ });
+
+ describe('getTypeValidators', () => {
+ it('should return two validators for type uint, secs and size', () => {
+ const types = ['uint', 'size', 'secs'];
+
+ types.forEach((valType) => {
+ const configOption = new ConfigFormModel();
+ configOption.type = valType;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.validators.length).toBe(2);
+ });
+ });
+
+ it('should return a validator for types float, int, addr and uuid', () => {
+ const types = ['float', 'int', 'addr', 'uuid'];
+
+ types.forEach((valType) => {
+ const configOption = new ConfigFormModel();
+ configOption.type = valType;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.validators.length).toBe(1);
+ });
+ });
+
+ it('should return undefined for type bool and str', () => {
+ const types = ['str', 'bool'];
+
+ types.forEach((valType) => {
+ const configOption = new ConfigFormModel();
+ configOption.type = valType;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeUndefined();
+ });
+ });
+
+ it('should return a pattern and a min validator', () => {
+ const configOption = new ConfigFormModel();
+ configOption.type = 'int';
+ configOption.min = 2;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.validators.length).toBe(2);
+ expect(ret.min).toBe(2);
+ expect(ret.max).toBeUndefined();
+ });
+
+ it('should return a pattern and a max validator', () => {
+ const configOption = new ConfigFormModel();
+ configOption.type = 'int';
+ configOption.max = 5;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.validators.length).toBe(2);
+ expect(ret.min).toBeUndefined();
+ expect(ret.max).toBe(5);
+ });
+
+ it('should return multiple validators', () => {
+ const configOption = new ConfigFormModel();
+ configOption.type = 'float';
+ configOption.max = 5.2;
+ configOption.min = 1.5;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.validators.length).toBe(3);
+ expect(ret.min).toBe(1.5);
+ expect(ret.max).toBe(5.2);
+ });
+
+ it(
+ 'should return a pattern help text for type uint, int, size, secs, ' + 'float, addr and uuid',
+ () => {
+ const types = ['uint', 'int', 'size', 'secs', 'float', 'addr', 'uuid'];
+
+ types.forEach((valType) => {
+ const configOption = new ConfigFormModel();
+ configOption.type = valType;
+
+ const ret = ConfigOptionTypes.getTypeValidators(configOption);
+ expect(ret).toBeTruthy();
+ expect(ret.patternHelpText).toBeDefined();
+ });
+ }
+ );
+ });
+
+ describe('getTypeStep', () => {
+ it('should return the correct step for type uint and value 0', () => {
+ const ret = ConfigOptionTypes.getTypeStep('uint', 0);
+ expect(ret).toBe(1);
+ });
+
+ it('should return the correct step for type int and value 1', () => {
+ const ret = ConfigOptionTypes.getTypeStep('int', 1);
+ expect(ret).toBe(1);
+ });
+
+ it('should return the correct step for type int and value null', () => {
+ const ret = ConfigOptionTypes.getTypeStep('int', null);
+ expect(ret).toBe(1);
+ });
+
+ it('should return the correct step for type size and value 2', () => {
+ const ret = ConfigOptionTypes.getTypeStep('size', 2);
+ expect(ret).toBe(1);
+ });
+
+ it('should return the correct step for type secs and value 3', () => {
+ const ret = ConfigOptionTypes.getTypeStep('secs', 3);
+ expect(ret).toBe(1);
+ });
+
+ it('should return the correct step for type float and value 1', () => {
+ const ret = ConfigOptionTypes.getTypeStep('float', 1);
+ expect(ret).toBe(0.1);
+ });
+
+ it('should return the correct step for type float and value 0.1', () => {
+ const ret = ConfigOptionTypes.getTypeStep('float', 0.1);
+ expect(ret).toBe(0.1);
+ });
+
+ it('should return the correct step for type float and value 0.02', () => {
+ const ret = ConfigOptionTypes.getTypeStep('float', 0.02);
+ expect(ret).toBe(0.01);
+ });
+
+ it('should return the correct step for type float and value 0.003', () => {
+ const ret = ConfigOptionTypes.getTypeStep('float', 0.003);
+ expect(ret).toBe(0.001);
+ });
+
+ it('should return the correct step for type float and value null', () => {
+ const ret = ConfigOptionTypes.getTypeStep('float', null);
+ expect(ret).toBe(0.1);
+ });
+
+ it('should return undefined for unknown type', () => {
+ const ret = ConfigOptionTypes.getTypeStep('unknown', 1);
+ expect(ret).toBeUndefined();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts
new file mode 100644
index 000000000..33336652c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts
@@ -0,0 +1,147 @@
+import { Validators } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { ConfigFormModel } from './config-option.model';
+
+export class ConfigOptionTypes {
+ // TODO: I18N
+ private static knownTypes: Array<any> = [
+ {
+ name: 'uint',
+ inputType: 'number',
+ humanReadable: 'Unsigned integer value',
+ defaultMin: 0,
+ patternHelpText: 'The entered value needs to be an unsigned number.',
+ isNumberType: true,
+ allowsNegative: false
+ },
+ {
+ name: 'int',
+ inputType: 'number',
+ humanReadable: 'Integer value',
+ patternHelpText: 'The entered value needs to be a number.',
+ isNumberType: true,
+ allowsNegative: true
+ },
+ {
+ name: 'size',
+ inputType: 'number',
+ humanReadable: 'Unsigned integer value (>=16bit)',
+ defaultMin: 0,
+ patternHelpText: 'The entered value needs to be a unsigned number.',
+ isNumberType: true,
+ allowsNegative: false
+ },
+ {
+ name: 'secs',
+ inputType: 'number',
+ humanReadable: 'Number of seconds',
+ defaultMin: 1,
+ patternHelpText: 'The entered value needs to be a number >= 1.',
+ isNumberType: true,
+ allowsNegative: false
+ },
+ {
+ name: 'float',
+ inputType: 'number',
+ humanReadable: 'Double value',
+ patternHelpText: 'The entered value needs to be a number or decimal.',
+ isNumberType: true,
+ allowsNegative: true
+ },
+ { name: 'str', inputType: 'text', humanReadable: 'Text', isNumberType: false },
+ {
+ name: 'addr',
+ inputType: 'text',
+ humanReadable: 'IPv4 or IPv6 address',
+ patternHelpText: 'The entered value needs to be a valid IP address.',
+ isNumberType: false
+ },
+ {
+ name: 'uuid',
+ inputType: 'text',
+ humanReadable: 'UUID',
+ patternHelpText:
+ 'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8',
+ isNumberType: false
+ },
+ { name: 'bool', inputType: 'checkbox', humanReadable: 'Boolean value', isNumberType: false }
+ ];
+
+ public static getType(type: string): any {
+ const currentType = _.find(this.knownTypes, (t) => {
+ return t.name === type;
+ });
+
+ if (currentType !== undefined) {
+ return currentType;
+ }
+
+ throw new Error('Found unknown type "' + type + '" for config option.');
+ }
+
+ public static getTypeValidators(configOption: ConfigFormModel): any {
+ const typeParams = ConfigOptionTypes.getType(configOption.type);
+
+ if (typeParams.name === 'bool' || typeParams.name === 'str') {
+ return;
+ }
+
+ const typeValidators: Record<string, any> = {
+ validators: [],
+ patternHelpText: typeParams.patternHelpText
+ };
+
+ if (typeParams.isNumberType) {
+ if (configOption.max && configOption.max !== '') {
+ typeValidators['max'] = configOption.max;
+ typeValidators.validators.push(Validators.max(configOption.max));
+ }
+
+ if (configOption.min && configOption.min !== '') {
+ typeValidators['min'] = configOption.min;
+ typeValidators.validators.push(Validators.min(configOption.min));
+ } else if ('defaultMin' in typeParams) {
+ typeValidators['min'] = typeParams.defaultMin;
+ typeValidators.validators.push(Validators.min(typeParams.defaultMin));
+ }
+
+ if (configOption.type === 'float') {
+ typeValidators.validators.push(CdValidators.decimalNumber());
+ } else {
+ typeValidators.validators.push(CdValidators.number(typeParams.allowsNegative));
+ }
+ } else if (configOption.type === 'addr') {
+ typeValidators.validators = [CdValidators.ip()];
+ } else if (configOption.type === 'uuid') {
+ typeValidators.validators = [CdValidators.uuid()];
+ }
+
+ return typeValidators;
+ }
+
+ public static getTypeStep(type: string, value: number): number | undefined {
+ const numberTypes = ['uint', 'int', 'size', 'secs'];
+
+ if (numberTypes.includes(type)) {
+ return 1;
+ }
+
+ if (type === 'float') {
+ if (value !== null) {
+ const stringVal = value.toString();
+ if (stringVal.indexOf('.') !== -1) {
+ // Value type float and contains decimal characters
+ const decimal = value.toString().split('.');
+ return Math.pow(10, -decimal[1].length);
+ }
+ }
+
+ return 0.1;
+ }
+
+ return undefined;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html
new file mode 100644
index 000000000..294d43f77
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html
@@ -0,0 +1,27 @@
+<cd-modal (hide)="cancel()">
+ <ng-container class="modal-title">
+ <span class="text-warning"
+ *ngIf="warning">
+ <i class="fa fa-exclamation-triangle fa-1x"></i>
+ </span>{{ titleText }}</ng-container>
+ <ng-container class="modal-content">
+ <form name="confirmationForm"
+ #formDir="ngForm"
+ [formGroup]="confirmationForm"
+ novalidate>
+ <div class="modal-body">
+ <ng-container *ngTemplateOutlet="bodyTpl; context: bodyContext"></ng-container>
+ <p *ngIf="description">
+ {{description}}
+ </p>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmit(confirmationForm.value)"
+ (backActionEvent)="boundCancel()"
+ [form]="confirmationForm"
+ [submitText]="buttonText"
+ [showSubmit]="showSubmit"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts
new file mode 100644
index 000000000..a76c5d378
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts
@@ -0,0 +1,185 @@
+import { Component, NgModule, NO_ERRORS_SCHEMA, TemplateRef, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal, NgbModalModule, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+
+import { ModalService } from '~/app/shared/services/modal.service';
+import { configureTestBed, FixtureHelper } from '~/testing/unit-test-helper';
+import { BackButtonComponent } from '../back-button/back-button.component';
+import { FormButtonPanelComponent } from '../form-button-panel/form-button-panel.component';
+import { ModalComponent } from '../modal/modal.component';
+import { SubmitButtonComponent } from '../submit-button/submit-button.component';
+import { ConfirmationModalComponent } from './confirmation-modal.component';
+
+@NgModule({})
+export class MockModule {}
+
+@Component({
+ template: `<ng-template #fillTpl>Template based description.</ng-template>`
+})
+class MockComponent {
+ @ViewChild('fillTpl', { static: true })
+ fillTpl: TemplateRef<any>;
+ modalRef: NgbModalRef;
+ returnValue: any;
+
+ // Normally private, but public is needed by tests
+ constructor(public modalService: ModalService) {}
+
+ private openModal(extendBaseState = {}) {
+ this.modalRef = this.modalService.show(
+ ConfirmationModalComponent,
+ Object.assign(
+ {
+ titleText: 'Title is a must have',
+ buttonText: 'Action label',
+ bodyTpl: this.fillTpl,
+ description: 'String based description.',
+ onSubmit: () => {
+ this.returnValue = 'The submit action has to hide manually.';
+ }
+ },
+ extendBaseState
+ )
+ );
+ }
+
+ basicModal() {
+ this.openModal();
+ }
+
+ customCancelModal() {
+ this.openModal({
+ onCancel: () => (this.returnValue = 'If you have todo something besides hiding the modal.')
+ });
+ }
+}
+
+describe('ConfirmationModalComponent', () => {
+ let component: ConfirmationModalComponent;
+ let fixture: ComponentFixture<ConfirmationModalComponent>;
+ let mockComponent: MockComponent;
+ let mockFixture: ComponentFixture<MockComponent>;
+ let fh: FixtureHelper;
+
+ const expectReturnValue = (v: string) => expect(mockComponent.returnValue).toBe(v);
+
+ configureTestBed({
+ declarations: [
+ ConfirmationModalComponent,
+ BackButtonComponent,
+ MockComponent,
+ ModalComponent,
+ SubmitButtonComponent,
+ FormButtonPanelComponent
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ imports: [ReactiveFormsModule, MockModule, RouterTestingModule, NgbModalModule],
+ providers: [NgbActiveModal, SubmitButtonComponent, FormButtonPanelComponent]
+ });
+
+ beforeEach(() => {
+ fh = new FixtureHelper();
+ mockFixture = TestBed.createComponent(MockComponent);
+ mockComponent = mockFixture.componentInstance;
+ mockFixture.detectChanges();
+
+ spyOn(TestBed.inject(ModalService), 'show').and.callFake((_modalComp, config) => {
+ fixture = TestBed.createComponent(ConfirmationModalComponent);
+ component = fixture.componentInstance;
+ component = Object.assign(component, config);
+ component.activeModal = { close: () => true } as any;
+ spyOn(component.activeModal, 'close').and.callThrough();
+ fh.updateFixture(fixture);
+ });
+ });
+
+ it('should create', () => {
+ mockComponent.basicModal();
+ expect(component).toBeTruthy();
+ });
+
+ describe('Throws errors', () => {
+ const expectError = (config: object, expected: string) => {
+ mockComponent.basicModal();
+ component = Object.assign(component, config);
+ expect(() => component.ngOnInit()).toThrowError(expected);
+ };
+
+ it('has no submit action defined', () => {
+ expectError(
+ {
+ onSubmit: undefined
+ },
+ 'No submit action defined'
+ );
+ });
+
+ it('has no title defined', () => {
+ expectError(
+ {
+ titleText: undefined
+ },
+ 'No title defined'
+ );
+ });
+
+ it('has no action name defined', () => {
+ expectError(
+ {
+ buttonText: undefined
+ },
+ 'No action name defined'
+ );
+ });
+
+ it('has no description defined', () => {
+ expectError(
+ {
+ bodyTpl: undefined,
+ description: undefined
+ },
+ 'No description defined'
+ );
+ });
+ });
+
+ describe('basics', () => {
+ beforeEach(() => {
+ mockComponent.basicModal();
+ spyOn(component, 'onSubmit').and.callThrough();
+ });
+
+ it('should show the correct title', () => {
+ expect(fh.getText('.modal-title')).toBe('Title is a must have');
+ });
+
+ it('should show the correct action name', () => {
+ expect(fh.getText('.tc_submitButton')).toBe('Action label');
+ });
+
+ it('should use the correct submit action', () => {
+ // In order to ignore the `ElementRef` usage of `SubmitButtonComponent`
+ spyOn(fh.getElementByCss('.tc_submitButton').componentInstance, 'focusButton');
+ fh.clickElement('.tc_submitButton');
+ expect(component.onSubmit).toHaveBeenCalledTimes(1);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(0);
+ expectReturnValue('The submit action has to hide manually.');
+ });
+
+ it('should use the default cancel action', () => {
+ fh.clickElement('.tc_backButton');
+ expect(component.onSubmit).toHaveBeenCalledTimes(0);
+ expect(component.activeModal.close).toHaveBeenCalledTimes(1);
+ expectReturnValue(undefined);
+ });
+
+ it('should show the description', () => {
+ expect(fh.getText('.modal-body')).toBe(
+ 'Template based description. String based description.'
+ );
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts
new file mode 100644
index 000000000..fe5624981
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts
@@ -0,0 +1,65 @@
+import { Component, OnDestroy, OnInit, TemplateRef } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+ selector: 'cd-confirmation-modal',
+ templateUrl: './confirmation-modal.component.html',
+ styleUrls: ['./confirmation-modal.component.scss']
+})
+export class ConfirmationModalComponent implements OnInit, OnDestroy {
+ // Needed
+ buttonText: string;
+ titleText: string;
+ onSubmit: Function;
+
+ // One of them is needed
+ bodyTpl?: TemplateRef<any>;
+ description?: TemplateRef<any>;
+
+ // Optional
+ warning = false;
+ bodyData?: object;
+ onCancel?: Function;
+ bodyContext?: object;
+ showSubmit = true;
+
+ // Component only
+ boundCancel = this.cancel.bind(this);
+ confirmationForm: FormGroup;
+ private canceled = false;
+
+ constructor(public activeModal: NgbActiveModal) {
+ this.confirmationForm = new FormGroup({});
+ }
+
+ ngOnInit() {
+ this.bodyContext = this.bodyContext || {};
+ this.bodyContext['$implicit'] = this.bodyData;
+ if (!this.onSubmit) {
+ throw new Error('No submit action defined');
+ } else if (!this.buttonText) {
+ throw new Error('No action name defined');
+ } else if (!this.titleText) {
+ throw new Error('No title defined');
+ } else if (!this.bodyTpl && !this.description) {
+ throw new Error('No description defined');
+ }
+ }
+
+ ngOnDestroy() {
+ if (this.onCancel && this.canceled) {
+ this.onCancel();
+ }
+ }
+
+ cancel() {
+ this.canceled = true;
+ this.activeModal.close();
+ }
+
+ stopLoadingSpinner() {
+ this.confirmationForm.setErrors({ cdSubmitButton: true });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html
new file mode 100644
index 000000000..25a3f3cfe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html
@@ -0,0 +1,7 @@
+<button (click)="onClick()"
+ type="button"
+ class="btn btn-light"
+ i18n-title
+ title="Copy to Clipboard">
+ <i [ngClass]="[icons.clipboard]"></i>
+</button>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts
new file mode 100644
index 000000000..2842793c6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts
@@ -0,0 +1,65 @@
+import { TestBed } from '@angular/core/testing';
+
+import * as BrowserDetect from 'detect-browser';
+import { ToastrService } from 'ngx-toastr';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { Copy2ClipboardButtonComponent } from './copy2clipboard-button.component';
+
+describe('Copy2ClipboardButtonComponent', () => {
+ let component: Copy2ClipboardButtonComponent;
+
+ configureTestBed({
+ providers: [
+ {
+ provide: ToastrService,
+ useValue: {
+ error: () => true,
+ success: () => true
+ }
+ }
+ ]
+ });
+
+ it('should create an instance', () => {
+ component = new Copy2ClipboardButtonComponent(null);
+ expect(component).toBeTruthy();
+ });
+
+ describe('test onClick behaviours', () => {
+ let toastrService: ToastrService;
+ let queryFn: jasmine.Spy;
+ let writeTextFn: jasmine.Spy;
+
+ beforeEach(() => {
+ toastrService = TestBed.inject(ToastrService);
+ component = new Copy2ClipboardButtonComponent(toastrService);
+ spyOn<any>(component, 'getText').and.returnValue('foo');
+ Object.assign(navigator, {
+ permissions: { query: jest.fn() },
+ clipboard: {
+ writeText: jest.fn()
+ }
+ });
+ queryFn = spyOn(navigator.permissions, 'query');
+ });
+
+ it('should not call permissions API', () => {
+ spyOn(BrowserDetect, 'detect').and.returnValue({ name: 'firefox' });
+ writeTextFn = spyOn(navigator.clipboard, 'writeText').and.returnValue(
+ new Promise<void>((resolve, _) => {
+ resolve();
+ })
+ );
+ component.onClick();
+ expect(queryFn).not.toHaveBeenCalled();
+ expect(writeTextFn).toHaveBeenCalledWith('foo');
+ });
+
+ it('should call permissions API', () => {
+ spyOn(BrowserDetect, 'detect').and.returnValue({ name: 'chrome' });
+ component.onClick();
+ expect(queryFn).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts
new file mode 100644
index 000000000..2cc656bfc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts
@@ -0,0 +1,55 @@
+import { Component, HostListener, Input } from '@angular/core';
+
+import { detect } from 'detect-browser';
+import { ToastrService } from 'ngx-toastr';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-copy-2-clipboard-button',
+ templateUrl: './copy2clipboard-button.component.html',
+ styleUrls: ['./copy2clipboard-button.component.scss']
+})
+export class Copy2ClipboardButtonComponent {
+ @Input()
+ private source: string;
+
+ @Input()
+ byId = true;
+
+ icons = Icons;
+
+ constructor(private toastr: ToastrService) {}
+
+ private getText(): string {
+ const element = document.getElementById(this.source) as HTMLInputElement;
+ return element.value;
+ }
+
+ @HostListener('click')
+ onClick() {
+ try {
+ const browser = detect();
+ const text = this.byId ? this.getText() : this.source;
+ const toastrFn = () => {
+ this.toastr.success('Copied text to the clipboard successfully.');
+ };
+ if (['firefox', 'ie', 'ios', 'safari'].includes(browser.name)) {
+ // Various browsers do not support the `Permissions API`.
+ // https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API#Browser_compatibility
+ navigator.clipboard.writeText(text).then(() => toastrFn());
+ } else {
+ // Checking if we have the clipboard-write permission
+ navigator.permissions
+ .query({ name: 'clipboard-write' as PermissionName })
+ .then((result: any) => {
+ if (result.state === 'granted' || result.state === 'prompt') {
+ navigator.clipboard.writeText(text).then(() => toastrFn());
+ }
+ });
+ }
+ } catch (_) {
+ this.toastr.error('Failed to copy text to the clipboard.');
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html
new file mode 100644
index 000000000..29b669b14
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html
@@ -0,0 +1,55 @@
+<cd-modal #modal
+ [modalRef]="activeModal">
+ <ng-container class="modal-title">
+ <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
+ </ng-container>
+
+ <ng-container class="modal-content">
+ <form name="deletionForm"
+ #formDir="ngForm"
+ [formGroup]="deletionForm"
+ novalidate>
+ <div class="modal-body">
+ <ng-container *ngTemplateOutlet="bodyTemplate; context: bodyContext"></ng-container>
+ <div class="question">
+ <span *ngIf="itemNames; else noNames">
+ <p *ngIf="itemNames.length === 1; else manyNames"
+ i18n>Are you sure that you want to {{ actionDescription | lowercase }} <strong>{{ itemNames[0] }}</strong>?</p>
+ <ng-template #manyNames>
+ <p i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected items?</p>
+ <ul>
+ <li *ngFor="let itemName of itemNames"><strong>{{ itemName }}</strong></li>
+ </ul>
+ </ng-template >
+ </span>
+ <ng-template #noNames>
+ <p i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected {{ itemDescription }}?</p>
+ </ng-template>
+ <ng-container *ngTemplateOutlet="childFormGroupTemplate; context:{form:deletionForm}"></ng-container>
+ <div class="form-group">
+ <div class="custom-control custom-checkbox">
+ <input type="checkbox"
+ class="custom-control-input"
+ name="confirmation"
+ id="confirmation"
+ formControlName="confirmation"
+ autofocus>
+ <label class="custom-control-label"
+ for="confirmation"
+ i18n>Yes, I am sure.</label>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="callSubmitAction()"
+ [form]="deletionForm"
+ [submitText]="(actionDescription | titlecase) + ' ' + itemDescription"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
+
+<ng-template #deletionHeading>
+ {{ actionDescription | titlecase }} {{ itemDescription }}
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss
new file mode 100644
index 000000000..979cb13fe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss
@@ -0,0 +1,11 @@
+.modal-body .question {
+ margin-top: 1em;
+}
+
+.modal-body label {
+ font-weight: bold;
+}
+
+.modal-body .question .form-check {
+ padding-top: 7px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts
new file mode 100644
index 000000000..e501d9f32
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts
@@ -0,0 +1,235 @@
+import { Component, NgModule, NO_ERRORS_SCHEMA, TemplateRef, ViewChild } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { NgForm, ReactiveFormsModule } from '@angular/forms';
+
+import { NgbActiveModal, NgbModalModule, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { Observable, Subscriber, timer as observableTimer } from 'rxjs';
+
+import { DirectivesModule } from '~/app/shared/directives/directives.module';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { configureTestBed, modalServiceShow } from '~/testing/unit-test-helper';
+import { AlertPanelComponent } from '../alert-panel/alert-panel.component';
+import { LoadingPanelComponent } from '../loading-panel/loading-panel.component';
+import { CriticalConfirmationModalComponent } from './critical-confirmation-modal.component';
+
+@NgModule({})
+export class MockModule {}
+
+@Component({
+ template: `
+ <button type="button" class="btn btn-danger" (click)="openCtrlDriven()">
+ <i class="fa fa-times"></i>Deletion Ctrl-Test
+ <ng-template #ctrlDescription>
+ The spinner is handled by the controller if you have use the modal as ViewChild in order to
+ use it's functions to stop the spinner or close the dialog.
+ </ng-template>
+ </button>
+
+ <button type="button" class="btn btn-danger" (click)="openModalDriven()">
+ <i class="fa fa-times"></i>Deletion Modal-Test
+ <ng-template #modalDescription>
+ The spinner is handled by the modal if your given deletion function returns a Observable.
+ </ng-template>
+ </button>
+ `
+})
+class MockComponent {
+ @ViewChild('ctrlDescription', { static: true })
+ ctrlDescription: TemplateRef<any>;
+ @ViewChild('modalDescription', { static: true })
+ modalDescription: TemplateRef<any>;
+ someData = [1, 2, 3, 4, 5];
+ finished: number[];
+ ctrlRef: NgbModalRef;
+ modalRef: NgbModalRef;
+
+ // Normally private - public was needed for the tests
+ constructor(public modalService: ModalService) {}
+
+ openCtrlDriven() {
+ this.ctrlRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ submitAction: this.fakeDeleteController.bind(this),
+ bodyTemplate: this.ctrlDescription
+ });
+ }
+
+ openModalDriven() {
+ this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+ submitActionObservable: this.fakeDelete(),
+ bodyTemplate: this.modalDescription
+ });
+ }
+
+ finish() {
+ this.finished = [6, 7, 8, 9];
+ }
+
+ fakeDelete() {
+ return (): Observable<any> => {
+ return new Observable((observer: Subscriber<any>) => {
+ observableTimer(100).subscribe(() => {
+ observer.next(this.finish());
+ observer.complete();
+ });
+ });
+ };
+ }
+
+ fakeDeleteController() {
+ observableTimer(100).subscribe(() => {
+ this.finish();
+ this.ctrlRef.close();
+ });
+ }
+}
+
+describe('CriticalConfirmationModalComponent', () => {
+ let mockComponent: MockComponent;
+ let component: CriticalConfirmationModalComponent;
+ let mockFixture: ComponentFixture<MockComponent>;
+
+ configureTestBed(
+ {
+ declarations: [
+ MockComponent,
+ CriticalConfirmationModalComponent,
+ LoadingPanelComponent,
+ AlertPanelComponent
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ imports: [ReactiveFormsModule, MockModule, DirectivesModule, NgbModalModule],
+ providers: [NgbActiveModal]
+ },
+ [CriticalConfirmationModalComponent]
+ );
+
+ beforeEach(() => {
+ mockFixture = TestBed.createComponent(MockComponent);
+ mockComponent = mockFixture.componentInstance;
+ spyOn(mockComponent.modalService, 'show').and.callFake((_modalComp, config) => {
+ const data = modalServiceShow(CriticalConfirmationModalComponent, config);
+ component = data.componentInstance;
+ return data;
+ });
+ mockComponent.openCtrlDriven();
+ mockFixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should throw an error if no action is defined', () => {
+ component = Object.assign(component, {
+ submitAction: null,
+ submitActionObservable: null
+ });
+ expect(() => component.ngOnInit()).toThrowError('No submit action defined');
+ });
+
+ it('should test if the ctrl driven mock is set correctly through mock component', () => {
+ expect(component.bodyTemplate).toBeTruthy();
+ expect(component.submitAction).toBeTruthy();
+ expect(component.submitActionObservable).not.toBeTruthy();
+ });
+
+ it('should test if the modal driven mock is set correctly through mock component', () => {
+ mockComponent.openModalDriven();
+ expect(component.bodyTemplate).toBeTruthy();
+ expect(component.submitActionObservable).toBeTruthy();
+ expect(component.submitAction).not.toBeTruthy();
+ });
+
+ describe('component functions', () => {
+ const changeValue = (value: boolean) => {
+ const ctrl = component.deletionForm.get('confirmation');
+ ctrl.setValue(value);
+ ctrl.markAsDirty();
+ ctrl.updateValueAndValidity();
+ mockFixture.detectChanges();
+ };
+
+ it('should test hideModal', () => {
+ expect(component.activeModal).toBeTruthy();
+ expect(component.hideModal).toBeTruthy();
+ spyOn(component.activeModal, 'close').and.callThrough();
+ expect(component.activeModal.close).not.toHaveBeenCalled();
+ component.hideModal();
+ expect(component.activeModal.close).toHaveBeenCalled();
+ });
+
+ describe('validate confirmation', () => {
+ const testValidation = (submitted: boolean, error: string, expected: boolean) => {
+ expect(
+ component.deletionForm.showError('confirmation', <NgForm>{ submitted: submitted }, error)
+ ).toBe(expected);
+ };
+
+ beforeEach(() => {
+ component.deletionForm.reset();
+ });
+
+ it('should test empty values', () => {
+ component.deletionForm.reset();
+ testValidation(false, undefined, false);
+ testValidation(true, 'required', true);
+ component.deletionForm.reset();
+ changeValue(true);
+ changeValue(false);
+ testValidation(true, 'required', true);
+ });
+ });
+
+ describe('deletion call', () => {
+ beforeEach(() => {
+ spyOn(component, 'stopLoadingSpinner').and.callThrough();
+ spyOn(component, 'hideModal').and.callThrough();
+ });
+
+ describe('Controller driven', () => {
+ beforeEach(() => {
+ spyOn(component, 'submitAction').and.callThrough();
+ spyOn(mockComponent.ctrlRef, 'close').and.callThrough();
+ });
+
+ it('should test fake deletion that closes modal', fakeAsync(() => {
+ // Before deletionCall
+ expect(component.submitAction).not.toHaveBeenCalled();
+ // During deletionCall
+ component.callSubmitAction();
+ expect(component.stopLoadingSpinner).not.toHaveBeenCalled();
+ expect(component.hideModal).not.toHaveBeenCalled();
+ expect(mockComponent.ctrlRef.close).not.toHaveBeenCalled();
+ expect(component.submitAction).toHaveBeenCalled();
+ expect(mockComponent.finished).toBe(undefined);
+ // After deletionCall
+ tick(2000);
+ expect(component.hideModal).not.toHaveBeenCalled();
+ expect(mockComponent.ctrlRef.close).toHaveBeenCalled();
+ expect(mockComponent.finished).toEqual([6, 7, 8, 9]);
+ }));
+ });
+
+ describe('Modal driven', () => {
+ beforeEach(() => {
+ mockComponent.openModalDriven();
+ spyOn(component, 'stopLoadingSpinner').and.callThrough();
+ spyOn(component, 'hideModal').and.callThrough();
+ spyOn(mockComponent, 'fakeDelete').and.callThrough();
+ });
+
+ it('should delete and close modal', fakeAsync(() => {
+ // During deletionCall
+ component.callSubmitAction();
+ expect(mockComponent.finished).toBe(undefined);
+ expect(component.hideModal).not.toHaveBeenCalled();
+ // After deletionCall
+ tick(2000);
+ expect(mockComponent.finished).toEqual([6, 7, 8, 9]);
+ expect(component.stopLoadingSpinner).not.toHaveBeenCalled();
+ expect(component.hideModal).toHaveBeenCalled();
+ }));
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts
new file mode 100644
index 000000000..4c634f8ca
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts
@@ -0,0 +1,63 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { Observable } from 'rxjs';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SubmitButtonComponent } from '../submit-button/submit-button.component';
+
+@Component({
+ selector: 'cd-deletion-modal',
+ templateUrl: './critical-confirmation-modal.component.html',
+ styleUrls: ['./critical-confirmation-modal.component.scss']
+})
+export class CriticalConfirmationModalComponent implements OnInit {
+ @ViewChild(SubmitButtonComponent, { static: true })
+ submitButton: SubmitButtonComponent;
+ bodyTemplate: TemplateRef<any>;
+ bodyContext: object;
+ submitActionObservable: () => Observable<any>;
+ submitAction: Function;
+ deletionForm: CdFormGroup;
+ itemDescription: 'entry';
+ itemNames: string[];
+ actionDescription = 'delete';
+
+ childFormGroup: CdFormGroup;
+ childFormGroupTemplate: TemplateRef<any>;
+
+ constructor(public activeModal: NgbActiveModal) {}
+
+ ngOnInit() {
+ const controls = {
+ confirmation: new FormControl(false, [Validators.requiredTrue])
+ };
+ if (this.childFormGroup) {
+ controls['child'] = this.childFormGroup;
+ }
+ this.deletionForm = new CdFormGroup(controls);
+ if (!(this.submitAction || this.submitActionObservable)) {
+ throw new Error('No submit action defined');
+ }
+ }
+
+ callSubmitAction() {
+ if (this.submitActionObservable) {
+ this.submitActionObservable().subscribe({
+ error: this.stopLoadingSpinner.bind(this),
+ complete: this.hideModal.bind(this)
+ });
+ } else {
+ this.submitAction();
+ }
+ }
+
+ hideModal() {
+ this.activeModal.close();
+ }
+
+ stopLoadingSpinner() {
+ this.deletionForm.setErrors({ cdSubmitButton: true });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html
new file mode 100644
index 000000000..7bb087c3f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html
@@ -0,0 +1,2 @@
+<p class="login-text"
+ *ngIf="bannerText$ | async as bannerText">{{ bannerText }}</p>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss
new file mode 100644
index 000000000..4721f6531
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss
@@ -0,0 +1,5 @@
+.login-text {
+ font-weight: bold;
+ margin: 0;
+ padding: 12px 20% 12px 12px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts
new file mode 100644
index 000000000..6005cbd0b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts
@@ -0,0 +1,25 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CustomLoginBannerComponent } from './custom-login-banner.component';
+
+describe('CustomLoginBannerComponent', () => {
+ let component: CustomLoginBannerComponent;
+ let fixture: ComponentFixture<CustomLoginBannerComponent>;
+
+ configureTestBed({
+ declarations: [CustomLoginBannerComponent],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CustomLoginBannerComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts
new file mode 100644
index 000000000..ad0d54688
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts
@@ -0,0 +1,20 @@
+import { Component, OnInit } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+
+import { CustomLoginBannerService } from '~/app/shared/api/custom-login-banner.service';
+
+@Component({
+ selector: 'cd-custom-login-banner',
+ templateUrl: './custom-login-banner.component.html',
+ styleUrls: ['./custom-login-banner.component.scss']
+})
+export class CustomLoginBannerComponent implements OnInit {
+ bannerText$: Observable<string>;
+ constructor(private customLoginBannerService: CustomLoginBannerService) {}
+
+ ngOnInit(): void {
+ this.bannerText$ = this.customLoginBannerService.getBannerText();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html
new file mode 100644
index 000000000..7f8388f47
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html
@@ -0,0 +1,13 @@
+<div class="d-flex justify-content-center">
+ <ngb-datepicker #dp
+ [(ngModel)]="date"
+ [minDate]="minDate"
+ (ngModelChange)="onModelChange()"></ngb-datepicker>
+</div>
+
+<div class="d-flex justify-content-center"
+ *ngIf="hasTime">
+ <ngb-timepicker [seconds]="hasSeconds"
+ [(ngModel)]="time"
+ (ngModelChange)="onModelChange()"></ngb-timepicker>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts
new file mode 100644
index 000000000..00d09e3b4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts
@@ -0,0 +1,58 @@
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { FormControl, FormsModule } from '@angular/forms';
+
+import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DateTimePickerComponent } from './date-time-picker.component';
+
+describe('DateTimePickerComponent', () => {
+ let component: DateTimePickerComponent;
+ let fixture: ComponentFixture<DateTimePickerComponent>;
+
+ configureTestBed({
+ declarations: [DateTimePickerComponent],
+ imports: [NgbDatepickerModule, NgbTimepickerModule, FormsModule]
+ });
+
+ beforeEach(() => {
+ spyOn(Date, 'now').and.returnValue(new Date('2022-02-22T00:00:00.00'));
+ fixture = TestBed.createComponent(DateTimePickerComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create with correct datetime', fakeAsync(() => {
+ component.control = new FormControl('2022-02-26 00:00:00');
+ fixture.detectChanges();
+ tick();
+ expect(component).toBeTruthy();
+ expect(component.control.value).toBe('2022-02-26 00:00:00');
+ }));
+
+ it('should update control value if datetime is not valid', fakeAsync(() => {
+ component.control = new FormControl('not valid');
+ fixture.detectChanges();
+ tick();
+ expect(component.control.value).toBe('2022-02-22 00:00:00');
+ }));
+
+ it('should init with only date enabled', () => {
+ component.control = new FormControl();
+ component.hasTime = false;
+ fixture.detectChanges();
+ expect(component.format).toBe('YYYY-MM-DD');
+ });
+
+ it('should init with time enabled', () => {
+ component.control = new FormControl();
+ component.hasSeconds = false;
+ fixture.detectChanges();
+ expect(component.format).toBe('YYYY-MM-DD HH:mm');
+ });
+
+ it('should init with seconds enabled', () => {
+ component.control = new FormControl();
+ fixture.detectChanges();
+ expect(component.format).toBe('YYYY-MM-DD HH:mm:ss');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts
new file mode 100644
index 000000000..390edbfd8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts
@@ -0,0 +1,67 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+
+import { NgbCalendar, NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
+import moment from 'moment';
+import { Subscription } from 'rxjs';
+
+@Component({
+ selector: 'cd-date-time-picker',
+ templateUrl: './date-time-picker.component.html',
+ styleUrls: ['./date-time-picker.component.scss']
+})
+export class DateTimePickerComponent implements OnInit {
+ @Input()
+ control: FormControl;
+
+ @Input()
+ hasSeconds = true;
+
+ @Input()
+ hasTime = true;
+
+ format: string;
+ minDate: NgbDateStruct;
+ date: NgbDateStruct;
+ time: NgbTimeStruct;
+
+ sub: Subscription;
+
+ constructor(private calendar: NgbCalendar) {}
+
+ ngOnInit() {
+ this.minDate = this.calendar.getToday();
+ if (!this.hasTime) {
+ this.format = 'YYYY-MM-DD';
+ } else if (this.hasSeconds) {
+ this.format = 'YYYY-MM-DD HH:mm:ss';
+ } else {
+ this.format = 'YYYY-MM-DD HH:mm';
+ }
+
+ let mom = moment(this.control?.value, this.format);
+
+ if (!mom.isValid() || mom.isBefore(moment())) {
+ mom = moment();
+ }
+
+ this.date = { year: mom.year(), month: mom.month() + 1, day: mom.date() };
+ this.time = { hour: mom.hour(), minute: mom.minute(), second: mom.second() };
+
+ this.onModelChange();
+ }
+
+ onModelChange() {
+ if (this.date) {
+ const datetime = Object.assign({}, this.date, this.time);
+ datetime.month--;
+ setTimeout(() => {
+ this.control.setValue(moment(datetime).format(this.format));
+ });
+ } else {
+ setTimeout(() => {
+ this.control.setValue('');
+ });
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html
new file mode 100644
index 000000000..b90fedc0c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html
@@ -0,0 +1,2 @@
+<a href="{{ docUrl }}"
+ target="_blank">{{ docText }}</a>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts
new file mode 100644
index 000000000..3fb31024e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts
@@ -0,0 +1,27 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CephReleaseNamePipe } from '~/app/shared/pipes/ceph-release-name.pipe';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DocComponent } from './doc.component';
+
+describe('DocComponent', () => {
+ let component: DocComponent;
+ let fixture: ComponentFixture<DocComponent>;
+
+ configureTestBed({
+ declarations: [DocComponent],
+ imports: [HttpClientTestingModule],
+ providers: [CephReleaseNamePipe]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DocComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts
new file mode 100644
index 000000000..6dffc360b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts
@@ -0,0 +1,28 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+import { DocService } from '~/app/shared/services/doc.service';
+
+@Component({
+ selector: 'cd-doc',
+ templateUrl: './doc.component.html',
+ styleUrls: ['./doc.component.scss']
+})
+export class DocComponent implements OnInit {
+ @Input() section: string;
+ @Input() docText = $localize`documentation`;
+ @Input() noSubscribe: boolean;
+
+ docUrl: string;
+
+ constructor(private docService: DocService) {}
+
+ ngOnInit() {
+ if (this.noSubscribe) {
+ this.docUrl = this.docService.urlGenerator(this.section);
+ } else {
+ this.docService.subscribeOnce(this.section, (url: string) => {
+ this.docUrl = url;
+ });
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html
new file mode 100644
index 000000000..a7e476501
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html
@@ -0,0 +1,23 @@
+<div ngbDropdown
+ placement="bottom-right">
+ <button type="button"
+ [title]="title"
+ class="btn btn-light dropdown-toggle-split"
+ ngbDropdownToggle>
+ <i [ngClass]="[icons.download]"></i>
+ </button>
+ <div ngbDropdownMenu>
+ <button ngbDropdownItem
+ (click)="download('json')"
+ *ngIf="objectItem">
+ <i [ngClass]="[icons.json]"></i>
+ <span>JSON</span>
+ </button>
+ <button ngbDropdownItem
+ (click)="download()"
+ *ngIf="textItem">
+ <i [ngClass]="[icons.text]"></i>
+ <span>Text</span>
+ </button>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts
new file mode 100644
index 000000000..7dbfc2b1c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts
@@ -0,0 +1,39 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TextToDownloadService } from '~/app/shared/services/text-to-download.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DownloadButtonComponent } from './download-button.component';
+
+describe('DownloadButtonComponent', () => {
+ let component: DownloadButtonComponent;
+ let fixture: ComponentFixture<DownloadButtonComponent>;
+
+ configureTestBed({
+ declarations: [DownloadButtonComponent],
+ providers: [TextToDownloadService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DownloadButtonComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should call download function', () => {
+ component.objectItem = {
+ testA: 'testA',
+ testB: 'testB'
+ };
+ const downloadSpy = spyOn(TestBed.inject(TextToDownloadService), 'download');
+ component.fileName = `${'reportText.json'}_${new Date().toLocaleDateString()}`;
+ component.download('json');
+ expect(downloadSpy).toHaveBeenCalledWith(
+ JSON.stringify(component.objectItem, null, 2),
+ `${component.fileName}.json`
+ );
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts
new file mode 100644
index 000000000..48fde7921
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts
@@ -0,0 +1,31 @@
+import { Component, Input } from '@angular/core';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { TextToDownloadService } from '~/app/shared/services/text-to-download.service';
+
+@Component({
+ selector: 'cd-download-button',
+ templateUrl: './download-button.component.html',
+ styleUrls: ['./download-button.component.scss']
+})
+export class DownloadButtonComponent {
+ @Input() objectItem: object;
+ @Input() textItem: string;
+ @Input() fileName: any;
+ @Input() title = $localize`Download`;
+
+ icons = Icons;
+ constructor(private textToDownloadService: TextToDownloadService) {}
+
+ download(format?: string) {
+ this.fileName = `${this.fileName}_${new Date().toLocaleDateString()}`;
+ if (format === 'json') {
+ this.textToDownloadService.download(
+ JSON.stringify(this.objectItem, null, 2),
+ `${this.fileName}.json`
+ );
+ } else {
+ this.textToDownloadService.download(this.textItem, `${this.fileName}.txt`);
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html
new file mode 100644
index 000000000..476ed9609
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html
@@ -0,0 +1,11 @@
+<div [class]="wrappingClass">
+ <cd-back-button class="m-2"
+ (backAction)="backAction()"
+ [name]="cancelText"></cd-back-button>
+ <cd-submit-button *ngIf="showSubmit"
+ (submitAction)="submitAction()"
+ [disabled]="disabled"
+ [form]="form"
+ [ariaLabel]="submitText"
+ data-cy="submitBtn">{{ submitText }}</cd-submit-button>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts
new file mode 100644
index 000000000..b8350485b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts
@@ -0,0 +1,25 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FormButtonPanelComponent } from './form-button-panel.component';
+
+describe('FormButtonPanelComponent', () => {
+ let component: FormButtonPanelComponent;
+ let fixture: ComponentFixture<FormButtonPanelComponent>;
+
+ configureTestBed({
+ declarations: [FormButtonPanelComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(FormButtonPanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts
new file mode 100644
index 000000000..0d48f63c0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts
@@ -0,0 +1,59 @@
+import { Location } from '@angular/common';
+import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { FormGroup, NgForm } from '@angular/forms';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { SubmitButtonComponent } from '../submit-button/submit-button.component';
+
+@Component({
+ selector: 'cd-form-button-panel',
+ templateUrl: './form-button-panel.component.html',
+ styleUrls: ['./form-button-panel.component.scss']
+})
+export class FormButtonPanelComponent {
+ @ViewChild(SubmitButtonComponent)
+ submitButton: SubmitButtonComponent;
+
+ @Output()
+ submitActionEvent = new EventEmitter();
+ @Output()
+ backActionEvent = new EventEmitter();
+
+ @Input()
+ form: FormGroup | NgForm;
+ @Input()
+ showSubmit = true;
+ @Input()
+ wrappingClass = '';
+ @Input()
+ btnClass = '';
+ @Input()
+ submitText: string = this.actionLabels.CREATE;
+ @Input()
+ cancelText: string = this.actionLabels.CANCEL;
+ @Input()
+ disabled = false;
+
+ constructor(
+ private location: Location,
+ private actionLabels: ActionLabelsI18n,
+ private modalService: ModalService
+ ) {}
+
+ submitAction() {
+ this.submitActionEvent.emit();
+ }
+
+ backAction() {
+ if (this.backActionEvent.observers.length === 0) {
+ if (this.modalService.hasOpenModals()) {
+ this.modalService.dismissAll();
+ } else {
+ this.location.back();
+ }
+ } else {
+ this.backActionEvent.emit();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
new file mode 100755
index 000000000..47fca49c7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
@@ -0,0 +1,69 @@
+<cd-modal [modalRef]="activeModal">
+ <ng-container *ngIf="titleText"
+ class="modal-title">
+ {{ titleText }}
+ </ng-container>
+ <ng-container class="modal-content">
+ <form [formGroup]="formGroup"
+ #formDir="ngForm"
+ novalidate>
+ <div class="modal-body">
+ <p *ngIf="message">{{ message }}</p>
+ <ng-container *ngFor="let field of fields">
+ <div class="form-group row cd-{{field.name}}-form-group">
+ <label *ngIf="field.label"
+ class="cd-col-form-label"
+ [ngClass]="{'required': field?.required === true}"
+ [for]="field.name">
+ {{ field.label }}
+ </label>
+ <div [ngClass]="{'cd-col-form-input': field.label, 'col-sm-12': !field.label}">
+ <input *ngIf="['text', 'number'].includes(field.type)"
+ [type]="field.type"
+ class="form-control"
+ [id]="field.name"
+ [name]="field.name"
+ [formControlName]="field.name">
+ <input *ngIf="field.type === 'binary'"
+ type="text"
+ class="form-control"
+ [id]="field.name"
+ [name]="field.name"
+ [formControlName]="field.name"
+ cdDimlessBinary>
+ <select *ngIf="field.type === 'select'"
+ class="form-control"
+ [id]="field.name"
+ [formControlName]="field.name">
+ <option *ngIf="field?.typeConfig?.placeholder"
+ [ngValue]="null">
+ {{ field?.typeConfig?.placeholder }}
+ </option>
+ <option *ngFor="let option of field?.typeConfig?.options"
+ [value]="option.value">
+ {{ option.text }}
+ </option>
+ </select>
+ <cd-select-badges *ngIf="field.type === 'select-badges'"
+ [id]="field.name"
+ [data]="field.value"
+ [customBadges]="field?.typeConfig?.customBadges"
+ [options]="field?.typeConfig?.options"
+ [messages]="field?.typeConfig?.messages">
+ </cd-select-badges>
+ <span *ngIf="formGroup.showError(field.name, formDir)"
+ class="invalid-feedback">
+ {{ getError(field) }}
+ </span>
+ </div>
+ </div>
+ </ng-container>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="onSubmitForm(formGroup.value)"
+ [form]="formGroup"
+ [submitText]="submitButtonText"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss
new file mode 100755
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts
new file mode 100755
index 000000000..219c2e79f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts
@@ -0,0 +1,149 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule, Validators } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed, FixtureHelper, FormHelper } from '~/testing/unit-test-helper';
+import { FormModalComponent } from './form-modal.component';
+
+describe('InputModalComponent', () => {
+ let component: FormModalComponent;
+ let fixture: ComponentFixture<FormModalComponent>;
+ let fh: FixtureHelper;
+ let formHelper: FormHelper;
+ let submitted: object;
+
+ const initialState = {
+ titleText: 'Some title',
+ message: 'Some description',
+ fields: [
+ {
+ type: 'text',
+ name: 'requiredField',
+ value: 'some-value',
+ required: true
+ },
+ {
+ type: 'number',
+ name: 'optionalField',
+ label: 'Optional',
+ errors: { min: 'Value has to be above zero!' },
+ validators: [Validators.min(0), Validators.max(10)]
+ },
+ {
+ type: 'binary',
+ name: 'dimlessBinary',
+ label: 'Size',
+ value: 2048,
+ validators: [CdValidators.binaryMin(1024), CdValidators.binaryMax(3072)]
+ }
+ ],
+ submitButtonText: 'Submit button name',
+ onSubmit: (values: object) => (submitted = values)
+ };
+
+ configureTestBed({
+ imports: [RouterTestingModule, ReactiveFormsModule, SharedModule],
+ providers: [NgbActiveModal]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(FormModalComponent);
+ component = fixture.componentInstance;
+ Object.assign(component, initialState);
+ fixture.detectChanges();
+ fh = new FixtureHelper(fixture);
+ formHelper = new FormHelper(component.formGroup);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('has the defined title', () => {
+ fh.expectTextToBe('.modal-title', 'Some title');
+ });
+
+ it('has the defined description', () => {
+ fh.expectTextToBe('.modal-body > p', 'Some description');
+ });
+
+ it('should display both inputs', () => {
+ fh.expectElementVisible('#requiredField', true);
+ fh.expectElementVisible('#optionalField', true);
+ });
+
+ it('has one defined label field', () => {
+ fh.expectTextToBe('.cd-col-form-label', 'Optional');
+ });
+
+ it('has a predefined values for requiredField', () => {
+ fh.expectFormFieldToBe('#requiredField', 'some-value');
+ });
+
+ it('gives back all form values on submit', () => {
+ component.onSubmitForm(component.formGroup.value);
+ expect(submitted).toEqual({
+ dimlessBinary: 2048,
+ requiredField: 'some-value',
+ optionalField: null
+ });
+ });
+
+ it('tests required field validation', () => {
+ formHelper.expectErrorChange('requiredField', '', 'required');
+ });
+
+ it('tests required field message', () => {
+ formHelper.setValue('requiredField', '', true);
+ fh.expectTextToBe('.cd-requiredField-form-group .invalid-feedback', 'This field is required.');
+ });
+
+ it('tests custom validator on number field', () => {
+ formHelper.expectErrorChange('optionalField', -1, 'min');
+ formHelper.expectErrorChange('optionalField', 11, 'max');
+ });
+
+ it('tests custom validator error message', () => {
+ formHelper.setValue('optionalField', -1, true);
+ fh.expectTextToBe(
+ '.cd-optionalField-form-group .invalid-feedback',
+ 'Value has to be above zero!'
+ );
+ });
+
+ it('tests default error message', () => {
+ formHelper.setValue('optionalField', 11, true);
+ fh.expectTextToBe('.cd-optionalField-form-group .invalid-feedback', 'An error occurred.');
+ });
+
+ it('tests binary error messages', () => {
+ formHelper.setValue('dimlessBinary', '4 K', true);
+ fh.expectTextToBe(
+ '.cd-dimlessBinary-form-group .invalid-feedback',
+ 'Size has to be at most 3 KiB or less'
+ );
+ formHelper.setValue('dimlessBinary', '0.5 K', true);
+ fh.expectTextToBe(
+ '.cd-dimlessBinary-form-group .invalid-feedback',
+ 'Size has to be at least 1 KiB or more'
+ );
+ });
+
+ it('shows result of dimlessBinary pipe', () => {
+ fh.expectFormFieldToBe('#dimlessBinary', '2 KiB');
+ });
+
+ it('changes dimlessBinary value and the result will still be a number', () => {
+ formHelper.setValue('dimlessBinary', '3 K', true);
+ component.onSubmitForm(component.formGroup.value);
+ expect(submitted).toEqual({
+ dimlessBinary: 3072,
+ requiredField: 'some-value',
+ optionalField: null
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts
new file mode 100755
index 000000000..46dd942e9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts
@@ -0,0 +1,110 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, ValidatorFn, Validators } from '@angular/forms';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdFormModalFieldConfig } from '~/app/shared/models/cd-form-modal-field-config';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+@Component({
+ selector: 'cd-form-modal',
+ templateUrl: './form-modal.component.html',
+ styleUrls: ['./form-modal.component.scss']
+})
+export class FormModalComponent implements OnInit {
+ // Input
+ titleText: string;
+ message: string;
+ fields: CdFormModalFieldConfig[];
+ submitButtonText: string;
+ onSubmit: Function;
+
+ // Internal
+ formGroup: CdFormGroup;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private formBuilder: CdFormBuilder,
+ private formatter: FormatterService,
+ private dimlessBinaryPipe: DimlessBinaryPipe
+ ) {}
+
+ ngOnInit() {
+ this.createForm();
+ }
+
+ createForm() {
+ const controlsConfig: Record<string, FormControl> = {};
+ this.fields.forEach((field) => {
+ controlsConfig[field.name] = this.createFormControl(field);
+ });
+ this.formGroup = this.formBuilder.group(controlsConfig);
+ }
+
+ private createFormControl(field: CdFormModalFieldConfig): FormControl {
+ let validators: ValidatorFn[] = [];
+ if (_.isBoolean(field.required) && field.required) {
+ validators.push(Validators.required);
+ }
+ if (field.validators) {
+ validators = validators.concat(field.validators);
+ }
+ return new FormControl(
+ _.defaultTo(
+ field.type === 'binary' ? this.dimlessBinaryPipe.transform(field.value) : field.value,
+ null
+ ),
+ { validators }
+ );
+ }
+
+ getError(field: CdFormModalFieldConfig): string {
+ const formErrors = this.formGroup.get(field.name).errors;
+ const errors = Object.keys(formErrors).map((key) => {
+ return this.getErrorMessage(key, formErrors[key], field.errors);
+ });
+ return errors.join('<br>');
+ }
+
+ private getErrorMessage(
+ error: string,
+ errorContext: any,
+ fieldErrors: { [error: string]: string }
+ ): string {
+ if (fieldErrors) {
+ const customError = fieldErrors[error];
+ if (customError) {
+ return customError;
+ }
+ }
+ if (['binaryMin', 'binaryMax'].includes(error)) {
+ // binaryMin and binaryMax return a function that take I18n to
+ // provide a translated error message.
+ return errorContext();
+ }
+ if (error === 'required') {
+ return $localize`This field is required.`;
+ }
+ return $localize`An error occurred.`;
+ }
+
+ onSubmitForm(values: any) {
+ const binaries = this.fields
+ .filter((field) => field.type === 'binary')
+ .map((field) => field.name);
+ binaries.forEach((key) => {
+ const value = values[key];
+ if (value) {
+ values[key] = this.formatter.toBytes(value);
+ }
+ });
+ this.activeModal.close();
+ if (_.isFunction(this.onSubmit)) {
+ this.onSubmit(values);
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html
new file mode 100644
index 000000000..8ad98b27f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html
@@ -0,0 +1,78 @@
+<!-- Embed dashboard -->
+<cd-loading-panel *ngIf="loading && grafanaExist"
+ i18n>Loading panel data...</cd-loading-panel>
+
+<cd-alert-panel type="info"
+ *ngIf="!grafanaExist"
+ i18n>Please consult the <cd-doc section="grafana"></cd-doc> on
+ how to configure and enable the monitoring functionality.</cd-alert-panel>
+
+<cd-alert-panel type="info"
+ *ngIf="!dashboardExist"
+ i18n>Grafana Dashboard doesn't exist. Please refer to
+ <cd-doc section="grafana"></cd-doc> on how to add dashboards to Grafana.</cd-alert-panel>
+
+<ng-container *ngIf="grafanaExist && dashboardExist">
+ <div class="row">
+ <div class="col">
+ <div class="form-inline timepicker">
+ <label for="timepicker"
+ class="ml-1 my-1"
+ i18n>Grafana Time Picker</label>
+
+ <select id="timepicker"
+ name="timepicker"
+ class="custom-select my-1 mx-3"
+ [(ngModel)]="time"
+ (ngModelChange)="onTimepickerChange($event)">
+ <option *ngFor="let key of grafanaTimes"
+ [ngValue]="key.value">{{ key.name }}
+ </option>
+ </select>
+
+ <button class="btn btn-light my-1"
+ i18n-title
+ title="Reset Settings"
+ (click)="reset()">
+ <i [ngClass]="[icons.undo]"></i>
+ </button>
+ <button class="btn btn-light my-1 ml-3"
+ i18n-title
+ title="Show hidden information"
+ (click)="showMessage = !showMessage">
+ <i [ngClass]="[icons.infoCircle, icons.large]"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col my-3"
+ *ngIf="showMessage">
+ <cd-alert-panel type="info"
+ class="mb-3"
+ *ngIf="showMessage"
+ dismissible="true"
+ (dismissed)="showMessage = false"
+ i18n>If no embedded Grafana Dashboard appeared below, please follow <a [href]="grafanaSrc"
+ target="_blank"
+ noopener
+ noreferrer>this link </a> to check if Grafana is reachable and there are no HTTPS certificate issues. You may need to reload this page after accepting any Browser certificate exceptions</cd-alert-panel>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col">
+ <div class="grafana-container">
+ <iframe #iframe
+ id="iframe"
+ [src]="grafanaSrc"
+ class="grafana"
+ [ngClass]="panelStyle"
+ frameborder="0"
+ scrolling="no">
+ </iframe>
+ </div>
+ </div>
+ </div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss
new file mode 100644
index 000000000..7b43a460f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss
@@ -0,0 +1,33 @@
+.grafana {
+ height: 600px;
+ width: 100%;
+ z-index: 0;
+}
+
+.grafana_one {
+ height: 400px;
+}
+
+.grafana_two {
+ height: 750px;
+}
+
+.grafana_three {
+ height: 900px;
+}
+
+.grafana_four {
+ height: 1160px;
+}
+
+.timepicker {
+ label {
+ font-weight: 700;
+ }
+}
+
+.dropdown-menu {
+ left: auto;
+ right: 20px;
+ top: 20px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts
new file mode 100644
index 000000000..63733fd75
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts
@@ -0,0 +1,81 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
+import { of } from 'rxjs';
+
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { CephReleaseNamePipe } from '~/app/shared/pipes/ceph-release-name.pipe';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AlertPanelComponent } from '../alert-panel/alert-panel.component';
+import { DocComponent } from '../doc/doc.component';
+import { LoadingPanelComponent } from '../loading-panel/loading-panel.component';
+import { GrafanaComponent } from './grafana.component';
+
+describe('GrafanaComponent', () => {
+ let component: GrafanaComponent;
+ let fixture: ComponentFixture<GrafanaComponent>;
+ const expected_url =
+ 'http:localhost:3000/d/foo/somePath&refresh=2s&var-datasource=Dashboard1&kiosk&from=now-1h&to=now';
+
+ configureTestBed({
+ declarations: [GrafanaComponent, AlertPanelComponent, LoadingPanelComponent, DocComponent],
+ imports: [NgbAlertModule, HttpClientTestingModule, RouterTestingModule, FormsModule],
+ providers: [CephReleaseNamePipe, SettingsService, SummaryService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(GrafanaComponent);
+ component = fixture.componentInstance;
+ component.grafanaPath = 'somePath';
+ component.uid = 'foo';
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have found out that grafana does not exist', () => {
+ fixture.detectChanges();
+ expect(component.grafanaExist).toBe(false);
+ expect(component.baseUrl).toBe(undefined);
+ expect(component.loading).toBe(true);
+ expect(component.url).toBe(undefined);
+ expect(component.grafanaSrc).toEqual(undefined);
+ });
+
+ describe('with grafana initialized', () => {
+ beforeEach(() => {
+ TestBed.inject(SettingsService)['settings'] = { 'api/grafana/url': 'http:localhost:3000' };
+ fixture.detectChanges();
+ });
+
+ it('should have found out that grafana exists and dashboard exists', () => {
+ expect(component.time).toBe('from=now-1h&to=now');
+ expect(component.grafanaExist).toBe(true);
+ expect(component.baseUrl).toBe('http:localhost:3000/d/');
+ expect(component.loading).toBe(false);
+ expect(component.url).toBe(expected_url);
+ expect(component.grafanaSrc).toEqual({
+ changingThisBreaksApplicationSecurity: expected_url
+ });
+ });
+
+ it('should reset the values', () => {
+ component.reset();
+ expect(component.time).toBe('from=now-1h&to=now');
+ expect(component.url).toBe(expected_url);
+ expect(component.grafanaSrc).toEqual({
+ changingThisBreaksApplicationSecurity: expected_url
+ });
+ });
+
+ it('should have Dashboard', () => {
+ TestBed.inject(SettingsService).validateGrafanaDashboardUrl = () => of({ uid: 200 });
+ expect(component.dashboardExist).toBe(true);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts
new file mode 100644
index 000000000..2815160ab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts
@@ -0,0 +1,201 @@
+import { Component, Input, OnChanges, OnInit } from '@angular/core';
+import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
+
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-grafana',
+ templateUrl: './grafana.component.html',
+ styleUrls: ['./grafana.component.scss']
+})
+export class GrafanaComponent implements OnInit, OnChanges {
+ grafanaSrc: SafeUrl;
+ url: string;
+ protocol: string;
+ host: string;
+ port: number;
+ baseUrl: any;
+ panelStyle: any;
+ grafanaExist = false;
+ mode = '&kiosk';
+ datasource = 'Dashboard1';
+ loading = true;
+ styles: Record<string, string> = {};
+ dashboardExist = true;
+ showMessage = false;
+ time: string;
+ grafanaTimes: any;
+ icons = Icons;
+ readonly DEFAULT_TIME: string = 'from=now-1h&to=now';
+
+ @Input()
+ grafanaPath: string;
+ @Input()
+ grafanaStyle: string;
+ @Input()
+ uid: string;
+
+ constructor(private sanitizer: DomSanitizer, private settingsService: SettingsService) {
+ this.grafanaTimes = [
+ {
+ name: $localize`Last 5 minutes`,
+ value: 'from=now-5m&to=now'
+ },
+ {
+ name: $localize`Last 15 minutes`,
+ value: 'from=now-15m&to=now'
+ },
+ {
+ name: $localize`Last 30 minutes`,
+ value: 'from=now-30m&to=now'
+ },
+ {
+ name: $localize`Last 1 hour (Default)`,
+ value: 'from=now-1h&to=now'
+ },
+ {
+ name: $localize`Last 3 hours`,
+ value: 'from=now-3h&to=now'
+ },
+ {
+ name: $localize`Last 6 hours`,
+ value: 'from=now-6h&to=now'
+ },
+ {
+ name: $localize`Last 12 hours`,
+ value: 'from=now-12h&to=now'
+ },
+ {
+ name: $localize`Last 24 hours`,
+ value: 'from=now-24h&to=now'
+ },
+ {
+ name: $localize`Yesterday`,
+ value: 'from=now-1d%2Fd&to=now-1d%2Fd'
+ },
+ {
+ name: $localize`Today so far`,
+ value: 'from=now%2Fd&to=now'
+ },
+ {
+ name: $localize`Day before yesterday`,
+ value: 'from=now-2d%2Fd&to=now-2d%2Fd'
+ },
+ {
+ name: $localize`Last 2 days`,
+ value: 'from=now-2d&to=now'
+ },
+ {
+ name: $localize`This day last week`,
+ value: 'from=now-7d%2Fd&to=now-7d%2Fd'
+ },
+ {
+ name: $localize`Previous week`,
+ value: 'from=now-1w%2Fw&to=now-1w%2Fw'
+ },
+ {
+ name: $localize`This week so far`,
+ value: 'from=now%2Fw&to=now'
+ },
+ {
+ name: $localize`Last 7 days`,
+ value: 'from=now-7d&to=now'
+ },
+ {
+ name: $localize`Previous month`,
+ value: 'from=now-1M%2FM&to=now-1M%2FM'
+ },
+ {
+ name: $localize`This month so far`,
+ value: 'from=now%2FM&to=now'
+ },
+ {
+ name: $localize`Last 30 days`,
+ value: 'from=now-30d&to=now'
+ },
+ {
+ name: $localize`Last 90 days`,
+ value: 'from=now-90d&to=now'
+ },
+ {
+ name: $localize`Last 6 months`,
+ value: 'from=now-6M&to=now'
+ },
+ {
+ name: $localize`Last 1 year`,
+ value: 'from=now-1y&to=now'
+ },
+ {
+ name: $localize`Previous year`,
+ value: 'from=now-1y%2Fy&to=now-1y%2Fy'
+ },
+ {
+ name: $localize`This year so far`,
+ value: 'from=now%2Fy&to=now'
+ },
+ {
+ name: $localize`Last 2 years`,
+ value: 'from=now-2y&to=now'
+ },
+ {
+ name: $localize`Last 5 years`,
+ value: 'from=now-5y&to=now'
+ }
+ ];
+ }
+
+ ngOnInit() {
+ this.time = this.DEFAULT_TIME;
+ this.styles = {
+ one: 'grafana_one',
+ two: 'grafana_two',
+ three: 'grafana_three',
+ four: 'grafana_four'
+ };
+
+ this.settingsService.ifSettingConfigured('api/grafana/url', (url) => {
+ this.grafanaExist = true;
+ this.loading = false;
+ this.baseUrl = url + '/d/';
+ this.getFrame();
+ });
+ this.panelStyle = this.styles[this.grafanaStyle];
+ }
+
+ getFrame() {
+ this.settingsService
+ .validateGrafanaDashboardUrl(this.uid)
+ .subscribe((data: any) => (this.dashboardExist = data === 200));
+ this.url =
+ this.baseUrl +
+ this.uid +
+ '/' +
+ this.grafanaPath +
+ '&refresh=2s' +
+ `&var-datasource=${this.datasource}` +
+ this.mode +
+ '&' +
+ this.time;
+ this.grafanaSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.url);
+ }
+
+ onTimepickerChange() {
+ if (this.grafanaExist) {
+ this.getFrame();
+ }
+ }
+
+ reset() {
+ this.time = this.DEFAULT_TIME;
+ if (this.grafanaExist) {
+ this.getFrame();
+ }
+ }
+
+ ngOnChanges() {
+ if (this.grafanaExist) {
+ this.getFrame();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html
new file mode 100644
index 000000000..f7bc12b5b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html
@@ -0,0 +1,11 @@
+<ng-template #popoverTpl>
+ <div [class]="class"
+ [innerHtml]="html">
+ </div>
+ <ng-content></ng-content>
+</ng-template>
+<i [ngClass]="[icons.questionCircle]"
+ aria-hidden="true"
+ [ngbPopover]="popoverTpl"
+ (click)="$event.preventDefault();">
+</i>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss
new file mode 100644
index 000000000..861b607cb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss
@@ -0,0 +1,7 @@
+@use './src/styles/vendor/variables' as vv;
+
+i {
+ color: vv.$primary;
+ cursor: pointer;
+ padding-left: 4px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts
new file mode 100644
index 000000000..a7ef4b35e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts
@@ -0,0 +1,26 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HelperComponent } from './helper.component';
+
+describe('HelperComponent', () => {
+ let component: HelperComponent;
+ let fixture: ComponentFixture<HelperComponent>;
+
+ configureTestBed({
+ imports: [NgbPopoverModule],
+ declarations: [HelperComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(HelperComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts
new file mode 100644
index 000000000..0028945ba
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts
@@ -0,0 +1,18 @@
+import { Component, Input } from '@angular/core';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-helper',
+ templateUrl: './helper.component.html',
+ styleUrls: ['./helper.component.scss']
+})
+export class HelperComponent {
+ @Input()
+ class: string;
+
+ @Input()
+ html: any;
+
+ icons = Icons;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html
new file mode 100644
index 000000000..2ecbbd7cc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html
@@ -0,0 +1,17 @@
+<div ngbDropdown
+ display="dynamic"
+ placement="bottom-right">
+ <a ngbDropdownToggle
+ i18n-title
+ title="Select a Language">
+ {{ allLanguages[selectedLanguage] }}
+ </a>
+ <div ngbDropdownMenu>
+ <ng-container *ngFor="let lang of supportedLanguages | keyvalue">
+ <button ngbDropdownItem
+ (click)="changeLanguage(lang.key)">
+ {{ lang.value }}
+ </button>
+ </ng-container>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts
new file mode 100644
index 000000000..5c8334e5a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts
@@ -0,0 +1,85 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LanguageSelectorComponent } from './language-selector.component';
+
+describe('LanguageSelectorComponent', () => {
+ let component: LanguageSelectorComponent;
+ let fixture: ComponentFixture<LanguageSelectorComponent>;
+
+ configureTestBed({
+ declarations: [LanguageSelectorComponent],
+ imports: [FormsModule, HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LanguageSelectorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ spyOn(component, 'reloadWindow').and.callFake(() => component.ngOnInit());
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should read current language', () => {
+ expect(component.selectedLanguage).toBe('en-US');
+ });
+
+ const expectLanguageChange = (lang: string) => {
+ component.changeLanguage(lang);
+ const cookie = document.cookie.split(';').filter((item) => item.includes(`cd-lang=${lang}`));
+ expect(cookie.length).toBe(1);
+ };
+
+ it('should change to cs', () => {
+ expectLanguageChange('cs');
+ });
+
+ it('should change to de', () => {
+ expectLanguageChange('de');
+ });
+
+ it('should change to es', () => {
+ expectLanguageChange('es');
+ });
+
+ it('should change to fr', () => {
+ expectLanguageChange('fr');
+ });
+
+ it('should change to id', () => {
+ expectLanguageChange('id');
+ });
+
+ it('should change to it', () => {
+ expectLanguageChange('it');
+ });
+
+ it('should change to ja', () => {
+ expectLanguageChange('ja');
+ });
+
+ it('should change to ko', () => {
+ expectLanguageChange('ko');
+ });
+
+ it('should change to pl', () => {
+ expectLanguageChange('pl');
+ });
+
+ it('should change to pt', () => {
+ expectLanguageChange('pt');
+ });
+
+ it('should change to zh-Hans', () => {
+ expectLanguageChange('zh-Hans');
+ });
+
+ it('should change to zh-Hant', () => {
+ expectLanguageChange('zh-Hant');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts
new file mode 100644
index 000000000..d747add20
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts
@@ -0,0 +1,40 @@
+import { Component, OnInit } from '@angular/core';
+
+import _ from 'lodash';
+
+import { LanguageService } from '~/app/shared/services/language.service';
+import { SupportedLanguages } from './supported-languages.enum';
+
+@Component({
+ selector: 'cd-language-selector',
+ templateUrl: './language-selector.component.html',
+ styleUrls: ['./language-selector.component.scss']
+})
+export class LanguageSelectorComponent implements OnInit {
+ allLanguages = SupportedLanguages;
+ supportedLanguages: Record<string, any> = {};
+ selectedLanguage: string;
+
+ constructor(private languageService: LanguageService) {}
+
+ ngOnInit() {
+ this.selectedLanguage = this.languageService.getLocale();
+
+ this.languageService.getLanguages().subscribe((langs) => {
+ this.supportedLanguages = _.pick(SupportedLanguages, langs) as Object;
+ });
+ }
+
+ /**
+ * Jest is being more restricted regarding spying on the reload method.
+ * This will allow us to spyOn this method instead.
+ */
+ reloadWindow() {
+ window.location.reload();
+ }
+
+ changeLanguage(lang: string) {
+ this.languageService.setLocale(lang);
+ this.reloadWindow();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts
new file mode 100644
index 000000000..8b573cf64
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts
@@ -0,0 +1,17 @@
+// When adding a new supported language make sure to add a test for it in:
+// language-selector.component.spec.ts
+export enum SupportedLanguages {
+ 'cs' = 'Čeština',
+ 'de' = 'Deutsch',
+ 'en-US' = 'English',
+ 'es' = 'Español',
+ 'fr' = 'Français',
+ 'id' = 'Bahasa Indonesia',
+ 'it' = 'Italiano',
+ 'ja' = '日本語',
+ 'ko' = '한국어',
+ 'pl' = 'Polski',
+ 'pt' = 'Português (brasileiro)',
+ 'zh-Hans' = '中文 (简体)',
+ 'zh-Hant' = '中文 (繁體)'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html
new file mode 100644
index 000000000..35726cfbd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html
@@ -0,0 +1,9 @@
+<ngb-alert type="info"
+ [dismissible]="false">
+ <strong>
+ <i [ngClass]="[icons.spinner, icons.spin]"
+ aria-hidden="true"
+ class="mr-2"></i>
+ </strong>
+ <ng-content></ng-content>
+</ngb-alert>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts
new file mode 100644
index 000000000..ffc0aa57b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts
@@ -0,0 +1,26 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LoadingPanelComponent } from './loading-panel.component';
+
+describe('LoadingPanelComponent', () => {
+ let component: LoadingPanelComponent;
+ let fixture: ComponentFixture<LoadingPanelComponent>;
+
+ configureTestBed({
+ declarations: [LoadingPanelComponent],
+ imports: [NgbAlertModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LoadingPanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts
new file mode 100644
index 000000000..61fd01904
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-loading-panel',
+ templateUrl: './loading-panel.component.html',
+ styleUrls: ['./loading-panel.component.scss']
+})
+export class LoadingPanelComponent {
+ icons = Icons;
+}
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
new file mode 100644
index 000000000..657e0d605
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html
@@ -0,0 +1,19 @@
+<div [ngClass]="pageURL ? 'modal' : ''">
+ <div [ngClass]="pageURL ? 'modal-dialog' : ''">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title float-left">
+ <ng-content select=".modal-title"></ng-content>
+ </h4>
+ <button type="button"
+ class="close float-right"
+ aria-label="Close"
+ (click)="close()">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+
+ <ng-content select=".modal-content"></ng-content>
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss
new file mode 100644
index 000000000..ceeb61427
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss
@@ -0,0 +1,23 @@
+@use './src/styles/defaults/mixins';
+
+.modal-header {
+ @include mixins.hf;
+ border-radius: 5px 5px 0 0;
+}
+
+::ng-deep cd-modal {
+ .modal-footer {
+ @include mixins.hf;
+ border-radius: 0 0 5px 5px;
+ }
+
+ .modal-body {
+ max-height: 70vh;
+ overflow-x: hidden;
+ overflow-y: auto;
+ }
+}
+
+button.close {
+ outline: none;
+}
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
new file mode 100644
index 000000000..cf08bef10
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts
@@ -0,0 +1,54 @@
+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';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ModalComponent } from './modal.component';
+
+describe('ModalComponent', () => {
+ let component: ModalComponent;
+ let fixture: ComponentFixture<ModalComponent>;
+ let routerNavigateSpy: jasmine.Spy;
+
+ configureTestBed({
+ declarations: [ModalComponent],
+ imports: [RouterTestingModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ModalComponent);
+ component = fixture.componentInstance;
+ routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate');
+ routerNavigateSpy.and.returnValue(true);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should call the hide callback function', () => {
+ spyOn(component.hide, 'emit');
+ const nativeElement = fixture.nativeElement;
+ const button = nativeElement.querySelector('button');
+ button.dispatchEvent(new Event('click'));
+ fixture.detectChanges();
+ expect(component.hide.emit).toHaveBeenCalled();
+ });
+
+ it('should hide the modal', () => {
+ component.modalRef = new NgbActiveModal();
+ spyOn(component.modalRef, 'close');
+ 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
new file mode 100644
index 000000000..25e06e62a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts
@@ -0,0 +1,31 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+ selector: 'cd-modal',
+ templateUrl: './modal.component.html',
+ styleUrls: ['./modal.component.scss']
+})
+export class ModalComponent {
+ @Input()
+ modalRef: NgbActiveModal;
+ @Input()
+ pageURL: string;
+
+ /**
+ * Should be a function that is triggered when the modal is hidden.
+ */
+ @Output()
+ hide = new EventEmitter();
+
+ constructor(private router: Router) {}
+
+ 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/motd/motd.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html
new file mode 100644
index 000000000..2fbe5d7f8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html
@@ -0,0 +1,8 @@
+<cd-alert-panel *ngIf="motd"
+ size="slim"
+ [showTitle]="false"
+ [type]="motd.severity"
+ [dismissible]="motd.severity !== 'danger'"
+ (dismissed)="onDismissed()">
+ <span [innerHTML]="motd.message | sanitizeHtml"></span>
+</cd-alert-panel>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts
new file mode 100644
index 000000000..826a8a5d0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts
@@ -0,0 +1,26 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MotdComponent } from './motd.component';
+
+describe('MotdComponent', () => {
+ let component: MotdComponent;
+ let fixture: ComponentFixture<MotdComponent>;
+
+ configureTestBed({
+ imports: [DashboardModule, HttpClientTestingModule, SharedModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MotdComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts
new file mode 100644
index 000000000..297ef2764
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts
@@ -0,0 +1,33 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { Motd } from '~/app/shared/api/motd.service';
+import { MotdNotificationService } from '~/app/shared/services/motd-notification.service';
+
+@Component({
+ selector: 'cd-motd',
+ templateUrl: './motd.component.html',
+ styleUrls: ['./motd.component.scss']
+})
+export class MotdComponent implements OnInit, OnDestroy {
+ motd: Motd | undefined = undefined;
+
+ private subscription: Subscription;
+
+ constructor(private motdNotificationService: MotdNotificationService) {}
+
+ ngOnInit(): void {
+ this.subscription = this.motdNotificationService.motd$.subscribe((motd: Motd | undefined) => {
+ this.motd = motd;
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.subscription.unsubscribe();
+ }
+
+ onDismissed(): void {
+ this.motdNotificationService.hide();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html
new file mode 100644
index 000000000..bba23747b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html
@@ -0,0 +1,131 @@
+<ng-template #tasksTpl>
+ <!-- Executing -->
+ <div *ngFor="let executingTask of executingTasks; trackBy:trackByFn">
+ <div class="card tc_task border-0">
+ <div class="row no-gutters">
+ <div class="col-md-2 text-center">
+ <span [ngClass]="[icons.stack, icons.large2x]"
+ class="text-info">
+ <i [ngClass]="[icons.stack2x, icons.circle]"></i>
+ <i [ngClass]="[icons.stack1x, icons.spinner, icons.spin, icons.inverse]"></i>
+ </span>
+ </div>
+ <div class="col-md-9">
+ <div class="card-body p-1">
+ <h6 class="card-title bold">{{ executingTask.description }}</h6>
+
+ <div class="mb-1">
+ <ngb-progressbar type="info"
+ [value]="executingTask?.progress"
+ [striped]="true"
+ [animated]="true"></ngb-progressbar>
+ </div>
+
+ <p class="card-text text-muted">
+ <small class="date float-left">
+ {{ executingTask.begin_time | cdDate }}
+ </small>
+
+ <span class="float-right">
+ {{ executingTask.progress || 0 }} %
+ </span>
+ </p>
+
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <hr>
+ </div>
+</ng-template>
+
+<ng-template #notificationsTpl>
+ <ng-container *ngIf="notifications.length > 0">
+ <button type="button"
+ class="btn btn-light btn-block"
+ (click)="removeAll(); $event.stopPropagation()">
+ <i [ngClass]="[icons.trash]"
+ aria-hidden="true"></i>
+ &nbsp;
+ <ng-container i18n>Clear notifications</ng-container>
+ </button>
+
+ <hr>
+
+ <div *ngFor="let notification of notifications; let i = index"
+ [ngClass]="notification.borderClass">
+ <div class="card tc_notification border-0">
+ <div class="row no-gutters">
+ <div class="col-md-2 text-center">
+ <span [ngClass]="[icons.stack, icons.large2x, notification.textClass]">
+ <i [ngClass]="[icons.circle, icons.stack2x]"></i>
+ <i [ngClass]="[icons.stack1x, icons.inverse, notification.iconClass]"></i>
+ </span>
+ </div>
+ <div class="col-md-10">
+ <div class="card-body p-1">
+ <button class="btn btn-link float-right mt-0 pt-0"
+ title="Remove notification"
+ i18n-title
+ (click)="remove(i); $event.stopPropagation()">
+ <i [ngClass]="[icons.trash]"></i>
+ </button>
+
+ <h6 class="card-title bold">{{ notification.title }}</h6>
+ <p class="card-text"
+ [innerHtml]="notification.message"></p>
+ <p class="card-text text-muted">
+ <ng-container *ngIf="notification.duration">
+ <small>
+ <ng-container i18n>Duration:</ng-container> {{ notification.duration | duration }}
+ </small>
+ <br>
+ </ng-container>
+ <small class="date"
+ [title]="notification.timestamp | cdDate">{{ notification.timestamp | relativeDate }}</small>
+ <i class="float-right custom-icon"
+ [ngClass]="[notification.applicationClass]"
+ [title]="notification.application"></i>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <hr>
+ </div>
+ </ng-container>
+</ng-template>
+
+<ng-template #emptyTpl>
+ <div *ngIf="notifications.length === 0 && executingTasks.length === 0">
+ <div class="message text-center"
+ i18n>There are no notifications.</div>
+ </div>
+</ng-template>
+
+<div class="card"
+ (clickOutside)="closeSidebar()"
+ [clickOutsideEnabled]="isSidebarOpened">
+ <div class="card-header">
+ <ng-container i18n>Tasks and Notifications</ng-container>
+
+ <button class="close float-right"
+ tabindex="-1"
+ type="button"
+ (click)="closeSidebar()">
+ <span>
+ <i [ngClass]="icons.close"></i>
+ </span>
+ </button>
+ </div>
+
+ <ngx-simplebar [options]="simplebar">
+ <div class="card-body">
+ <ng-container *ngTemplateOutlet="tasksTpl"></ng-container>
+ <ng-container *ngTemplateOutlet="notificationsTpl"></ng-container>
+ <ng-container *ngTemplateOutlet="emptyTpl"></ng-container>
+ </div>
+ </ngx-simplebar>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss
new file mode 100644
index 000000000..baa64fa1f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss
@@ -0,0 +1,68 @@
+@use './src/styles/vendor/variables' as vv;
+
+:host {
+ bottom: 10px;
+ max-width: 90vw;
+ position: fixed;
+ right: -350px;
+ top: vv.$navbar-height + 10px;
+
+ transition: all 0.6s;
+
+ width: 350px;
+
+ z-index: 9;
+}
+
+:host.active {
+ right: 20px;
+}
+
+.card {
+ height: 100%;
+}
+
+.card-body {
+ padding-left: 0;
+ padding-right: 5px;
+ padding-top: 3px;
+}
+
+ngx-simplebar {
+ height: calc(100% - 42.2px);
+}
+
+.separator {
+ background-color: vv.$gray-200;
+ color: vv.$gray-600;
+ font-size: 1rem;
+ padding: 5px 12px;
+}
+
+.btn-block {
+ width: 98%;
+}
+
+.btn-link .fa-trash-o {
+ color: vv.$black;
+}
+
+table {
+ width: 100%;
+}
+
+.row {
+ margin-left: 0;
+ margin-right: 0;
+ padding-bottom: 1rem;
+ padding-top: 1rem;
+}
+
+hr {
+ margin-bottom: 2px;
+ margin-top: 2px;
+}
+
+.card-text {
+ margin-right: 15px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts
new file mode 100644
index 000000000..596f3c358
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts
@@ -0,0 +1,194 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
+import { ClickOutsideModule } from 'ng-click-outside';
+import { ToastrModule } from 'ngx-toastr';
+import { SimplebarAngularModule } from 'simplebar-angular';
+
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { Permissions } from '~/app/shared/models/permissions';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NotificationsSidebarComponent } from './notifications-sidebar.component';
+
+describe('NotificationsSidebarComponent', () => {
+ let component: NotificationsSidebarComponent;
+ let fixture: ComponentFixture<NotificationsSidebarComponent>;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ PipesModule,
+ NgbProgressbarModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ NoopAnimationsModule,
+ SimplebarAngularModule,
+ ClickOutsideModule
+ ],
+ declarations: [NotificationsSidebarComponent],
+ providers: [PrometheusService, SettingsService, SummaryService, NotificationService, RbdService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NotificationsSidebarComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ describe('prometheus alert handling', () => {
+ let prometheusAlertService: PrometheusAlertService;
+ let prometheusNotificationService: PrometheusNotificationService;
+ let prometheusReadPermission: string;
+ let configOptReadPermission: string;
+
+ const expectPrometheusServicesToBeCalledTimes = (n: number) => {
+ expect(prometheusNotificationService.refresh).toHaveBeenCalledTimes(n);
+ expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(n);
+ };
+
+ beforeEach(() => {
+ prometheusReadPermission = 'read';
+ configOptReadPermission = 'read';
+ spyOn(TestBed.inject(AuthStorageService), 'getPermissions').and.callFake(
+ () =>
+ new Permissions({
+ prometheus: [prometheusReadPermission],
+ 'config-opt': [configOptReadPermission]
+ })
+ );
+
+ spyOn(TestBed.inject(PrometheusService), 'ifAlertmanagerConfigured').and.callFake((fn) =>
+ fn()
+ );
+
+ prometheusAlertService = TestBed.inject(PrometheusAlertService);
+ spyOn(prometheusAlertService, 'refresh').and.stub();
+
+ prometheusNotificationService = TestBed.inject(PrometheusNotificationService);
+ spyOn(prometheusNotificationService, 'refresh').and.stub();
+ });
+
+ it('should not refresh prometheus services if not allowed', () => {
+ prometheusReadPermission = '';
+ configOptReadPermission = 'read';
+ fixture.detectChanges();
+
+ expectPrometheusServicesToBeCalledTimes(0);
+
+ prometheusReadPermission = 'read';
+ configOptReadPermission = '';
+ fixture.detectChanges();
+
+ expectPrometheusServicesToBeCalledTimes(0);
+ });
+
+ it('should first refresh prometheus notifications and alerts during init', () => {
+ fixture.detectChanges();
+
+ expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(1);
+ expectPrometheusServicesToBeCalledTimes(1);
+ });
+
+ it('should refresh prometheus services every 5s', fakeAsync(() => {
+ fixture.detectChanges();
+
+ expectPrometheusServicesToBeCalledTimes(1);
+ tick(5000);
+ expectPrometheusServicesToBeCalledTimes(2);
+ tick(15000);
+ expectPrometheusServicesToBeCalledTimes(5);
+ component.ngOnDestroy();
+ }));
+ });
+
+ describe('Running Tasks', () => {
+ let summaryService: SummaryService;
+
+ beforeEach(() => {
+ fixture.detectChanges();
+ summaryService = TestBed.inject(SummaryService);
+
+ spyOn(component, '_handleTasks').and.callThrough();
+ });
+
+ it('should handle executing tasks', () => {
+ const running_tasks = new ExecutingTask('rbd/delete', {
+ image_spec: 'somePool/someImage'
+ });
+
+ summaryService['summaryDataSource'].next({ executing_tasks: [running_tasks] });
+
+ expect(component._handleTasks).toHaveBeenCalled();
+ expect(component.executingTasks.length).toBe(1);
+ expect(component.executingTasks[0].description).toBe(`Deleting RBD 'somePool/someImage'`);
+ });
+ });
+
+ describe('Notifications', () => {
+ it('should fetch latest notifications', fakeAsync(() => {
+ const notificationService: NotificationService = TestBed.inject(NotificationService);
+ fixture.detectChanges();
+
+ expect(component.notifications.length).toBe(0);
+
+ notificationService.show(NotificationType.success, 'Sample title', 'Sample message');
+ tick(6000);
+ expect(component.notifications.length).toBe(1);
+ expect(component.notifications[0].title).toBe('Sample title');
+ }));
+ });
+
+ describe('Sidebar', () => {
+ let notificationService: NotificationService;
+
+ beforeEach(() => {
+ notificationService = TestBed.inject(NotificationService);
+ fixture.detectChanges();
+ });
+
+ it('should always close if sidebarSubject value is true', fakeAsync(() => {
+ // Closed before next value
+ expect(component.isSidebarOpened).toBeFalsy();
+ notificationService.sidebarSubject.next(true);
+ tick();
+ expect(component.isSidebarOpened).toBeFalsy();
+
+ // Opened before next value
+ component.isSidebarOpened = true;
+ expect(component.isSidebarOpened).toBeTruthy();
+ notificationService.sidebarSubject.next(true);
+ tick();
+ expect(component.isSidebarOpened).toBeFalsy();
+ }));
+
+ it('should toggle sidebar visibility if sidebarSubject value is false', () => {
+ // Closed before next value
+ expect(component.isSidebarOpened).toBeFalsy();
+ notificationService.sidebarSubject.next(false);
+ expect(component.isSidebarOpened).toBeTruthy();
+
+ // Opened before next value
+ component.isSidebarOpened = true;
+ expect(component.isSidebarOpened).toBeTruthy();
+ notificationService.sidebarSubject.next(false);
+ expect(component.isSidebarOpened).toBeFalsy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts
new file mode 100644
index 000000000..8c5caf7ff
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts
@@ -0,0 +1,167 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ HostBinding,
+ NgZone,
+ OnDestroy,
+ OnInit
+} from '@angular/core';
+
+import { Mutex } from 'async-mutex';
+import _ from 'lodash';
+import moment from 'moment';
+import { Subscription } from 'rxjs';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdNotification } from '~/app/shared/models/cd-notification';
+import { ExecutingTask } from '~/app/shared/models/executing-task';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { TaskMessageService } from '~/app/shared/services/task-message.service';
+
+@Component({
+ selector: 'cd-notifications-sidebar',
+ templateUrl: './notifications-sidebar.component.html',
+ styleUrls: ['./notifications-sidebar.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class NotificationsSidebarComponent implements OnInit, OnDestroy {
+ @HostBinding('class.active') isSidebarOpened = false;
+
+ notifications: CdNotification[];
+ private interval: number;
+ private timeout: number;
+
+ executingTasks: ExecutingTask[] = [];
+
+ private subs = new Subscription();
+
+ icons = Icons;
+
+ // Tasks
+ last_task = '';
+ mutex = new Mutex();
+
+ simplebar = {
+ autoHide: false
+ };
+
+ constructor(
+ public notificationService: NotificationService,
+ private summaryService: SummaryService,
+ private taskMessageService: TaskMessageService,
+ private prometheusNotificationService: PrometheusNotificationService,
+ private authStorageService: AuthStorageService,
+ private prometheusAlertService: PrometheusAlertService,
+ private ngZone: NgZone,
+ private cdRef: ChangeDetectorRef
+ ) {
+ this.notifications = [];
+ }
+
+ ngOnDestroy() {
+ window.clearInterval(this.interval);
+ window.clearTimeout(this.timeout);
+ this.subs.unsubscribe();
+ }
+
+ ngOnInit() {
+ this.last_task = window.localStorage.getItem('last_task');
+
+ const permissions = this.authStorageService.getPermissions();
+ if (permissions.prometheus.read && permissions.configOpt.read) {
+ this.triggerPrometheusAlerts();
+ this.ngZone.runOutsideAngular(() => {
+ this.interval = window.setInterval(() => {
+ this.ngZone.run(() => {
+ this.triggerPrometheusAlerts();
+ });
+ }, 5000);
+ });
+ }
+
+ this.subs.add(
+ this.notificationService.data$.subscribe((notifications: CdNotification[]) => {
+ this.notifications = _.orderBy(notifications, ['timestamp'], ['desc']);
+ this.cdRef.detectChanges();
+ })
+ );
+
+ this.subs.add(
+ this.notificationService.sidebarSubject.subscribe((forceClose) => {
+ if (forceClose) {
+ this.isSidebarOpened = false;
+ } else {
+ this.isSidebarOpened = !this.isSidebarOpened;
+ }
+
+ window.clearTimeout(this.timeout);
+ this.timeout = window.setTimeout(() => {
+ this.cdRef.detectChanges();
+ }, 0);
+ })
+ );
+
+ this.subs.add(
+ this.summaryService.subscribe((summary) => {
+ this._handleTasks(summary.executing_tasks);
+
+ this.mutex.acquire().then((release) => {
+ _.filter(
+ summary.finished_tasks,
+ (task: FinishedTask) => !this.last_task || moment(task.end_time).isAfter(this.last_task)
+ ).forEach((task) => {
+ const config = this.notificationService.finishedTaskToNotification(task, task.success);
+ const notification = new CdNotification(config);
+ notification.timestamp = task.end_time;
+ notification.duration = task.duration;
+
+ if (!this.last_task || moment(task.end_time).isAfter(this.last_task)) {
+ this.last_task = task.end_time;
+ window.localStorage.setItem('last_task', this.last_task);
+ }
+
+ this.notificationService.save(notification);
+ });
+
+ this.cdRef.detectChanges();
+
+ release();
+ });
+ })
+ );
+ }
+
+ _handleTasks(executingTasks: ExecutingTask[]) {
+ for (const excutingTask of executingTasks) {
+ excutingTask.description = this.taskMessageService.getRunningTitle(excutingTask);
+ }
+ this.executingTasks = executingTasks;
+ }
+
+ private triggerPrometheusAlerts() {
+ this.prometheusAlertService.refresh();
+ this.prometheusNotificationService.refresh();
+ }
+
+ removeAll() {
+ this.notificationService.removeAll();
+ }
+
+ remove(index: number) {
+ this.notificationService.remove(index);
+ }
+
+ closeSidebar() {
+ this.isSidebarOpened = false;
+ }
+
+ trackByFn(index: number) {
+ return index;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html
new file mode 100644
index 000000000..f33261d80
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html
@@ -0,0 +1,10 @@
+<cd-alert-panel *ngIf="missingFeatures; else elseBlock"
+ type="info"
+ i18n>The feature is not supported in the current Orchestrator.</cd-alert-panel>
+
+<ng-template #elseBlock>
+ <cd-alert-panel type="info"
+ i18n>Orchestrator is not available.
+ Please consult the <cd-doc section="orch"></cd-doc> on how to configure and
+ enable the functionality.</cd-alert-panel>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts
new file mode 100644
index 000000000..2a3613474
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts
@@ -0,0 +1,29 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { CephReleaseNamePipe } from '~/app/shared/pipes/ceph-release-name.pipe';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ComponentsModule } from '../components.module';
+import { OrchestratorDocPanelComponent } from './orchestrator-doc-panel.component';
+
+describe('OrchestratorDocPanelComponent', () => {
+ let component: OrchestratorDocPanelComponent;
+ let fixture: ComponentFixture<OrchestratorDocPanelComponent>;
+
+ configureTestBed({
+ imports: [ComponentsModule, HttpClientTestingModule, RouterTestingModule],
+ providers: [CephReleaseNamePipe, SummaryService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(OrchestratorDocPanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts
new file mode 100644
index 000000000..d5bc36ad6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts
@@ -0,0 +1,13 @@
+import { Component, Input } from '@angular/core';
+
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+
+@Component({
+ selector: 'cd-orchestrator-doc-panel',
+ templateUrl: './orchestrator-doc-panel.component.html',
+ styleUrls: ['./orchestrator-doc-panel.component.scss']
+})
+export class OrchestratorDocPanelComponent {
+ @Input()
+ missingFeatures: OrchestratorFeature[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html
new file mode 100644
index 000000000..b1bc5150a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html
@@ -0,0 +1,16 @@
+<cd-alert-panel class="no-margin-bottom"
+ [type]="alertType"
+ *ngIf="displayNotification"
+ [showTitle]="false"
+ size="slim"
+ [dismissible]="alertType !== 'danger'"
+ (dismissed)="onDismissed()">
+ <div *ngIf="expirationDays === 0"
+ i18n>Your password will expire in <strong>less than 1</strong> day. Click
+ <a routerLink="/user-profile/edit"
+ class="alert-link">here</a> to change it now.</div>
+ <div *ngIf="expirationDays > 0"
+ i18n>Your password will expire in <strong>{{ expirationDays }}</strong> day(s). Click
+ <a routerLink="/user-profile/edit"
+ class="alert-link">here</a> to change it now.</div>
+</cd-alert-panel>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss
new file mode 100644
index 000000000..dc5cdeb84
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss
@@ -0,0 +1,3 @@
+.no-margin-bottom {
+ margin-bottom: 0;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts
new file mode 100644
index 000000000..597f5bab3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts
@@ -0,0 +1,107 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
+import { of as observableOf } from 'rxjs';
+
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { AlertPanelComponent } from '~/app/shared/components/alert-panel/alert-panel.component';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { PwdExpirationNotificationComponent } from './pwd-expiration-notification.component';
+
+describe('PwdExpirationNotificationComponent', () => {
+ let component: PwdExpirationNotificationComponent;
+ let fixture: ComponentFixture<PwdExpirationNotificationComponent>;
+ let settingsService: SettingsService;
+ let authStorageService: AuthStorageService;
+
+ @Component({ selector: 'cd-fake', template: '' })
+ class FakeComponent {}
+
+ const routes: Routes = [{ path: 'login', component: FakeComponent }];
+
+ const spyOnDate = (fakeDate: string) => {
+ const dateValue = Date;
+ spyOn(global, 'Date').and.callFake((date) => new dateValue(date ? date : fakeDate));
+ };
+
+ configureTestBed({
+ declarations: [PwdExpirationNotificationComponent, FakeComponent, AlertPanelComponent],
+ imports: [NgbAlertModule, HttpClientTestingModule, RouterTestingModule.withRoutes(routes)],
+ providers: [SettingsService, AuthStorageService]
+ });
+
+ describe('password expiration date has been set', () => {
+ beforeEach(() => {
+ authStorageService = TestBed.inject(AuthStorageService);
+ settingsService = TestBed.inject(SettingsService);
+ spyOn(authStorageService, 'getPwdExpirationDate').and.returnValue(1645488000);
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ user_pwd_expiration_warning_1: 10,
+ user_pwd_expiration_warning_2: 5,
+ user_pwd_expiration_span: 90
+ })
+ );
+ fixture = TestBed.createComponent(PwdExpirationNotificationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ component.ngOnInit();
+ expect(component).toBeTruthy();
+ });
+
+ it('should set warning levels', () => {
+ component.ngOnInit();
+ expect(component.pwdExpirationSettings.pwdExpirationWarning1).toBe(10);
+ expect(component.pwdExpirationSettings.pwdExpirationWarning2).toBe(5);
+ });
+
+ it('should calculate password expiration in days', () => {
+ spyOnDate('2022-02-18T00:00:00.000Z');
+ component.ngOnInit();
+ expect(component['expirationDays']).toBe(4);
+ });
+
+ it('should set alert type warning correctly', () => {
+ spyOnDate('2022-02-14T00:00:00.000Z');
+ component.ngOnInit();
+ expect(component['alertType']).toBe('warning');
+ expect(component.displayNotification).toBeTruthy();
+ });
+
+ it('should set alert type danger correctly', () => {
+ spyOnDate('2022-02-18T00:00:00.000Z');
+ component.ngOnInit();
+ expect(component['alertType']).toBe('danger');
+ expect(component.displayNotification).toBeTruthy();
+ });
+
+ it('should not display if date is far', () => {
+ spyOnDate('2022-01-01T00:00:00.000Z');
+ component.ngOnInit();
+ expect(component.displayNotification).toBeFalsy();
+ });
+ });
+
+ describe('password expiration date has not been set', () => {
+ beforeEach(() => {
+ authStorageService = TestBed.inject(AuthStorageService);
+ spyOn(authStorageService, 'getPwdExpirationDate').and.returnValue(null);
+ fixture = TestBed.createComponent(PwdExpirationNotificationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should calculate no expirationDays', () => {
+ component.ngOnInit();
+ expect(component['expirationDays']).toBeUndefined();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts
new file mode 100644
index 000000000..3dd8b5455
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts
@@ -0,0 +1,55 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import { SettingsService } from '~/app/shared/api/settings.service';
+import { CdPwdExpirationSettings } from '~/app/shared/models/cd-pwd-expiration-settings';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+@Component({
+ selector: 'cd-pwd-expiration-notification',
+ templateUrl: './pwd-expiration-notification.component.html',
+ styleUrls: ['./pwd-expiration-notification.component.scss']
+})
+export class PwdExpirationNotificationComponent implements OnInit, OnDestroy {
+ alertType: string;
+ expirationDays: number;
+ pwdExpirationSettings: CdPwdExpirationSettings;
+ displayNotification = false;
+
+ constructor(
+ private settingsService: SettingsService,
+ private authStorageService: AuthStorageService
+ ) {}
+
+ ngOnInit() {
+ this.settingsService.getStandardSettings().subscribe((pwdExpirationSettings) => {
+ this.pwdExpirationSettings = new CdPwdExpirationSettings(pwdExpirationSettings);
+ const pwdExpirationDate = this.authStorageService.getPwdExpirationDate();
+ if (pwdExpirationDate) {
+ this.expirationDays = this.getExpirationDays(pwdExpirationDate);
+ if (this.expirationDays <= this.pwdExpirationSettings.pwdExpirationWarning2) {
+ this.alertType = 'danger';
+ } else {
+ this.alertType = 'warning';
+ }
+ this.displayNotification =
+ this.expirationDays <= this.pwdExpirationSettings.pwdExpirationWarning1;
+ this.authStorageService.isPwdDisplayedSource.next(this.displayNotification);
+ }
+ });
+ }
+
+ ngOnDestroy() {
+ this.authStorageService.isPwdDisplayedSource.next(false);
+ }
+
+ private getExpirationDays(pwdExpirationDate: number): number {
+ const current = new Date();
+ const expiration = new Date(pwdExpirationDate * 1000);
+ return Math.floor((expiration.valueOf() - current.valueOf()) / (1000 * 3600 * 24));
+ }
+
+ onDismissed(): void {
+ this.authStorageService.isPwdDisplayedSource.next(false);
+ this.displayNotification = false;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html
new file mode 100644
index 000000000..d33fc9af8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html
@@ -0,0 +1,19 @@
+<div class="container-fluid">
+ <div class="row">
+ <div class="col d-flex justify-content-end">
+ <form class="form-inline">
+ <label for="refreshInterval"
+ class="col-form-label my-0 mx-2"
+ i18n>Refresh</label>
+ <select id="refreshInterval"
+ name="refreshInterval"
+ class="form-control"
+ (change)="changeRefreshInterval($event.target.value)"
+ [(ngModel)]="selectedInterval">
+ <option *ngFor="let key of intervalKeys"
+ [value]="intervalList[key]">{{ key }}</option>
+ </select>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts
new file mode 100644
index 000000000..cb98cadd7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RefreshSelectorComponent } from './refresh-selector.component';
+
+describe('RefreshSelectorComponent', () => {
+ let component: RefreshSelectorComponent;
+ let fixture: ComponentFixture<RefreshSelectorComponent>;
+
+ configureTestBed({
+ imports: [FormsModule],
+ declarations: [RefreshSelectorComponent],
+ providers: [RefreshIntervalService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RefreshSelectorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts
new file mode 100644
index 000000000..080890e26
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts
@@ -0,0 +1,32 @@
+import { Component, OnInit } from '@angular/core';
+
+import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
+
+@Component({
+ selector: 'cd-refresh-selector',
+ templateUrl: './refresh-selector.component.html',
+ styleUrls: ['./refresh-selector.component.scss']
+})
+export class RefreshSelectorComponent implements OnInit {
+ selectedInterval: number;
+ intervalList: { [key: string]: number } = {
+ '5 s': 5000,
+ '10 s': 10000,
+ '15 s': 15000,
+ '30 s': 30000,
+ '1 min': 60000,
+ '3 min': 180000,
+ '5 min': 300000
+ };
+ intervalKeys = Object.keys(this.intervalList);
+
+ constructor(private refreshIntervalService: RefreshIntervalService) {}
+
+ ngOnInit() {
+ this.selectedInterval = this.refreshIntervalService.getRefreshInterval() || 5000;
+ }
+
+ changeRefreshInterval(interval: number) {
+ this.refreshIntervalService.setRefreshInterval(interval);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html
new file mode 100644
index 000000000..0f23aee87
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html
@@ -0,0 +1,22 @@
+<cd-select #cdSelect
+ [data]="data"
+ [options]="options"
+ [messages]="messages"
+ [selectionLimit]="selectionLimit"
+ [customBadges]="customBadges"
+ [customBadgeValidators]="customBadgeValidators"
+ elemClass="mr-2 select-menu-edit"
+ (selection)="selection.emit($event)">
+ <i [ngClass]="[icons.edit]"></i>
+</cd-select>
+
+<span *ngFor="let dataItem of data">
+ <span class="badge badge-dark mr-2">
+ <span class="mr-2">{{ dataItem }}</span>
+ <a class="badge-remove"
+ (click)="cdSelect.removeItem(dataItem)">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </a>
+ </span>
+</span>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss
new file mode 100644
index 000000000..e1271c5e4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss
@@ -0,0 +1,9 @@
+@use './src/styles/vendor/variables' as vv;
+
+.badge-remove {
+ color: vv.$white;
+}
+
+i.fa-pencil {
+ font-size: 1.1rem;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts
new file mode 100644
index 000000000..ac7323b73
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts
@@ -0,0 +1,57 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SelectMessages } from '../select/select-messages.model';
+import { SelectComponent } from '../select/select.component';
+import { SelectBadgesComponent } from './select-badges.component';
+
+describe('SelectBadgesComponent', () => {
+ let component: SelectBadgesComponent;
+ let fixture: ComponentFixture<SelectBadgesComponent>;
+
+ configureTestBed({
+ declarations: [SelectBadgesComponent, SelectComponent],
+ imports: [NgbPopoverModule, NgbTooltipModule, ReactiveFormsModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SelectBadgesComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should reflect the attributes into CdSelect', () => {
+ const data = ['a', 'b'];
+ const options = [
+ { name: 'option1', description: '', selected: false, enabled: true },
+ { name: 'option2', description: '', selected: false, enabled: true }
+ ];
+ const messages = new SelectMessages({ empty: 'foo bar' });
+ const selectionLimit = 2;
+ const customBadges = true;
+ const customBadgeValidators = [Validators.required];
+
+ component.data = data;
+ component.options = options;
+ component.messages = messages;
+ component.selectionLimit = selectionLimit;
+ component.customBadges = customBadges;
+ component.customBadgeValidators = customBadgeValidators;
+
+ fixture.detectChanges();
+
+ expect(component.cdSelect.data).toEqual(data);
+ expect(component.cdSelect.options).toEqual(options);
+ expect(component.cdSelect.messages).toEqual(messages);
+ expect(component.cdSelect.selectionLimit).toEqual(selectionLimit);
+ expect(component.cdSelect.customBadges).toEqual(customBadges);
+ expect(component.cdSelect.customBadgeValidators).toEqual(customBadgeValidators);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts
new file mode 100644
index 000000000..b44ecd7e4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts
@@ -0,0 +1,35 @@
+import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { ValidatorFn } from '@angular/forms';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { SelectMessages } from '../select/select-messages.model';
+import { SelectOption } from '../select/select-option.model';
+import { SelectComponent } from '../select/select.component';
+
+@Component({
+ selector: 'cd-select-badges',
+ templateUrl: './select-badges.component.html',
+ styleUrls: ['./select-badges.component.scss']
+})
+export class SelectBadgesComponent {
+ @Input()
+ data: Array<string> = [];
+ @Input()
+ options: Array<SelectOption> = [];
+ @Input()
+ messages = new SelectMessages({});
+ @Input()
+ selectionLimit: number;
+ @Input()
+ customBadges = false;
+ @Input()
+ customBadgeValidators: ValidatorFn[] = [];
+
+ @Output()
+ selection = new EventEmitter();
+
+ @ViewChild('cdSelect', { static: true })
+ cdSelect: SelectComponent;
+
+ icons = Icons;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts
new file mode 100644
index 000000000..7a28ffb5e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+
+export class SelectMessages {
+ empty: string;
+ selectionLimit: any;
+ customValidations = {};
+ filter: string;
+ add: string;
+ noOptions: string;
+
+ constructor(messages: {}) {
+ this.empty = $localize`No items selected.`;
+ this.selectionLimit = {
+ tooltip: $localize`Deselect item to select again`,
+ text: $localize`Selection limit reached`
+ };
+ this.filter = $localize`Filter tags`;
+ this.add = $localize`Add badge`; // followed by " '{{filter.value}}'"
+ this.noOptions = $localize`There are no items available.`;
+
+ _.merge(this, messages);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts
new file mode 100644
index 000000000..bbd970c6f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts
@@ -0,0 +1,13 @@
+export class SelectOption {
+ selected: boolean;
+ name: string;
+ description: string;
+ enabled: boolean;
+
+ constructor(selected: boolean, name: string, description: string, enabled = true) {
+ this.selected = selected;
+ this.name = name;
+ this.description = description;
+ this.enabled = enabled;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html
new file mode 100644
index 000000000..1533b9439
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html
@@ -0,0 +1,79 @@
+<ng-template #popTemplate>
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div>
+ <input type="text"
+ formControlName="filter"
+ i18n-placeholder
+ [placeholder]="messages.filter"
+ (keyup)="$event.keyCode == 13 ? selectOption() : updateFilter()"
+ class="form-control text-center" />
+ <ng-container *ngFor="let error of Object.keys(messages.customValidations)">
+ <span class="invalid-feedback text-center d-block"
+ *ngIf="form.showError('filter', formDir) && filter.hasError(error)">
+ {{ messages.customValidations[error] }}
+ </span>
+ </ng-container>
+ </div>
+ </form>
+ <div *ngFor="let option of filteredOptions"
+ class="select-menu-item"
+ [ngClass]="{'help-block disabled': (data.length === selectionLimit || !option.enabled) && !option.selected}"
+ (click)="triggerSelection(option)">
+ <div class="select-menu-item-icon">
+ <i [ngClass]="[icons.check]"
+ aria-hidden="true"
+ *ngIf="option.selected"></i>
+ &nbsp;
+ </div>
+ <div class="select-menu-item-content">
+ {{ option.name }}
+ <ng-container *ngIf="option.description">
+ <br>
+ <small class="form-text text-muted">
+ {{ option.description }}&nbsp;
+ </small>
+ </ng-container>
+ </div>
+ </div>
+ <div *ngIf="isCreatable()"
+ class="select-menu-item"
+ (click)="addCustomOption()">
+ <div class="select-menu-item-icon">
+ <i [ngClass]="[icons.tag]"
+ aria-hidden="true"></i>
+ &nbsp;
+ </div>
+ <div class="select-menu-item-content">
+ {{ messages.add }} '{{ filter.value }}'
+ </div>
+ </div>
+ <div class="is-invalid"
+ *ngIf="data.length === selectionLimit">
+ <span class="form-text text-muted text-center text-warning"
+ [ngbTooltip]="messages.selectionLimit.tooltip"
+ *ngIf="data.length === selectionLimit">
+ {{ messages.selectionLimit.text }}
+ </span>
+ </div>
+</ng-template>
+
+<a class="select-menu-edit float-left"
+ [ngClass]="elemClass"
+ [ngbPopover]="popTemplate"
+ data-testid="select-menu-edit"
+ *ngIf="customBadges || options.length > 0">
+ <ng-content></ng-content>
+</a>
+
+<span class="form-text text-muted float-left"
+ *ngIf="data.length === 0 && !(!customBadges && options.length === 0)">
+ {{ messages.empty }}
+</span>
+
+<span class="form-text text-muted float-left"
+ *ngIf="!customBadges && options.length === 0">
+ {{ messages.noOptions }}
+</span>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss
new file mode 100644
index 000000000..9a4b45062
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss
@@ -0,0 +1,26 @@
+@use './src/styles/vendor/variables' as vv;
+
+.select-menu-item {
+ border-bottom: 1px solid vv.$datatable-divider-color;
+ cursor: pointer;
+ display: block;
+ font-size: 1rem;
+
+ &:hover {
+ background-color: vv.$gray-200;
+ }
+}
+
+.select-menu-item-icon {
+ float: left;
+ padding: 0.5em;
+ width: 3em;
+}
+
+.select-menu-item-content {
+ padding: 0.5em;
+
+ .form-text {
+ display: flex;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts
new file mode 100644
index 000000000..c35ec9091
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts
@@ -0,0 +1,276 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SelectOption } from './select-option.model';
+import { SelectComponent } from './select.component';
+
+describe('SelectComponent', () => {
+ let component: SelectComponent;
+ let fixture: ComponentFixture<SelectComponent>;
+
+ const selectOption = (filter: string) => {
+ component.filter.setValue(filter);
+ component.updateFilter();
+ component.selectOption();
+ };
+
+ configureTestBed({
+ declarations: [SelectComponent],
+ imports: [NgbPopoverModule, NgbTooltipModule, ReactiveFormsModule]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SelectComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ component.options = [
+ { name: 'option1', description: '', selected: false, enabled: true },
+ { name: 'option2', description: '', selected: false, enabled: true },
+ { name: 'option3', description: '', selected: false, enabled: true }
+ ];
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should add item', () => {
+ component.data = [];
+ component.triggerSelection(component.options[1]);
+ expect(component.data).toEqual(['option2']);
+ });
+
+ it('should update selected', () => {
+ component.data = ['option2'];
+ component.ngOnChanges();
+ expect(component.options[0].selected).toBe(false);
+ expect(component.options[1].selected).toBe(true);
+ });
+
+ it('should remove item', () => {
+ component.options.map((option) => {
+ option.selected = true;
+ return option;
+ });
+ component.data = ['option1', 'option2', 'option3'];
+ component.removeItem('option1');
+ expect(component.data).toEqual(['option2', 'option3']);
+ });
+
+ it('should not remove item that is not selected', () => {
+ component.options[0].selected = true;
+ component.data = ['option1'];
+ component.removeItem('option2');
+ expect(component.data).toEqual(['option1']);
+ });
+
+ describe('filter values', () => {
+ beforeEach(() => {
+ component.ngOnInit();
+ });
+
+ it('shows all options with no value set', () => {
+ expect(component.filteredOptions).toEqual(component.options);
+ });
+
+ it('shows one option that it filtered for', () => {
+ component.filter.setValue('2');
+ component.updateFilter();
+ expect(component.filteredOptions).toEqual([component.options[1]]);
+ });
+
+ it('shows all options after selecting something', () => {
+ component.filter.setValue('2');
+ component.updateFilter();
+ component.selectOption();
+ expect(component.filteredOptions).toEqual(component.options);
+ });
+
+ it('is not able to create by default with no value set', () => {
+ component.updateFilter();
+ expect(component.isCreatable()).toBeFalsy();
+ });
+
+ it('is not able to create by default with a value set', () => {
+ component.filter.setValue('2');
+ component.updateFilter();
+ expect(component.isCreatable()).toBeFalsy();
+ });
+ });
+
+ describe('automatically add selected options if not in options array', () => {
+ beforeEach(() => {
+ component.data = ['option1', 'option4'];
+ expect(component.options.length).toBe(3);
+ });
+
+ const expectedResult = () => {
+ expect(component.options.length).toBe(4);
+ expect(component.options[3]).toEqual(new SelectOption(true, 'option4', ''));
+ };
+
+ it('with no extra settings', () => {
+ component.ngOnInit();
+ expectedResult();
+ });
+
+ it('with custom badges', () => {
+ component.customBadges = true;
+ component.ngOnInit();
+ expectedResult();
+ });
+
+ it('with limit higher than selected', () => {
+ component.selectionLimit = 3;
+ component.ngOnInit();
+ expectedResult();
+ });
+
+ it('with limit equal to selected', () => {
+ component.selectionLimit = 2;
+ component.ngOnInit();
+ expectedResult();
+ });
+
+ it('with limit lower than selected', () => {
+ component.selectionLimit = 1;
+ component.ngOnInit();
+ expectedResult();
+ });
+ });
+
+ describe('sorted array and options', () => {
+ beforeEach(() => {
+ component.customBadges = true;
+ component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
+ component.data = ['c', 'b'];
+ component.options = [new SelectOption(true, 'd', ''), new SelectOption(true, 'a', '')];
+ component.ngOnInit();
+ });
+
+ it('has a sorted selection', () => {
+ expect(component.data).toEqual(['a', 'b', 'c', 'd']);
+ });
+
+ it('has a sorted options', () => {
+ const sortedOptions = [
+ new SelectOption(true, 'a', ''),
+ new SelectOption(true, 'b', ''),
+ new SelectOption(true, 'c', ''),
+ new SelectOption(true, 'd', '')
+ ];
+ expect(component.options).toEqual(sortedOptions);
+ });
+
+ it('has a sorted selection after adding an item', () => {
+ selectOption('block');
+ expect(component.data).toEqual(['a', 'b', 'block', 'c', 'd']);
+ });
+
+ it('has a sorted options after adding an item', () => {
+ selectOption('block');
+ const sortedOptions = [
+ new SelectOption(true, 'a', ''),
+ new SelectOption(true, 'b', ''),
+ new SelectOption(true, 'block', ''),
+ new SelectOption(true, 'c', ''),
+ new SelectOption(true, 'd', '')
+ ];
+ expect(component.options).toEqual(sortedOptions);
+ });
+ });
+
+ describe('with custom options', () => {
+ beforeEach(() => {
+ component.customBadges = true;
+ component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
+ component.ngOnInit();
+ });
+
+ it('is not able to create with no value set', () => {
+ component.updateFilter();
+ expect(component.isCreatable()).toBeFalsy();
+ });
+
+ it('is able to create with a valid value set', () => {
+ component.filter.setValue('2');
+ component.updateFilter();
+ expect(component.isCreatable()).toBeTruthy();
+ });
+
+ it('is not able to create with a value set that already exist', () => {
+ component.filter.setValue('option2');
+ component.updateFilter();
+ expect(component.isCreatable()).toBeFalsy();
+ });
+
+ it('adds custom option', () => {
+ selectOption('customOption');
+ expect(component.options[0]).toEqual({
+ name: 'customOption',
+ description: '',
+ selected: true,
+ enabled: true
+ });
+ expect(component.options.length).toBe(4);
+ expect(component.data).toEqual(['customOption']);
+ });
+
+ it('will not add an option that did not pass the validation', () => {
+ selectOption(' this does not pass ');
+ expect(component.options.length).toBe(3);
+ expect(component.data).toEqual([]);
+ expect(component.filter.invalid).toBeTruthy();
+ });
+
+ it('removes custom item selection by name', () => {
+ selectOption('customOption');
+ component.removeItem('customOption');
+ expect(component.data).toEqual([]);
+ expect(component.options.length).toBe(4);
+ expect(component.options[0]).toEqual({
+ name: 'customOption',
+ description: '',
+ selected: false,
+ enabled: true
+ });
+ });
+
+ it('will not add an option that is already there', () => {
+ selectOption('option2');
+ expect(component.options.length).toBe(3);
+ expect(component.data).toEqual(['option2']);
+ });
+
+ it('will not add an option twice after each other', () => {
+ selectOption('onlyOnce');
+ expect(component.data).toEqual(['onlyOnce']);
+ selectOption('onlyOnce');
+ expect(component.data).toEqual([]);
+ selectOption('onlyOnce');
+ expect(component.data).toEqual(['onlyOnce']);
+ expect(component.options.length).toBe(4);
+ });
+ });
+
+ describe('if the selection limit is reached', function () {
+ beforeEach(() => {
+ component.selectionLimit = 2;
+ component.triggerSelection(component.options[0]);
+ component.triggerSelection(component.options[1]);
+ });
+
+ it('will not select more options', () => {
+ component.triggerSelection(component.options[2]);
+ expect(component.data).toEqual(['option1', 'option2']);
+ });
+
+ it('will unselect options that are selected', () => {
+ component.triggerSelection(component.options[1]);
+ expect(component.data).toEqual(['option1']);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts
new file mode 100644
index 000000000..3ff19fe04
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts
@@ -0,0 +1,149 @@
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
+import { FormControl, ValidatorFn } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SelectMessages } from './select-messages.model';
+import { SelectOption } from './select-option.model';
+
+@Component({
+ selector: 'cd-select',
+ templateUrl: './select.component.html',
+ styleUrls: ['./select.component.scss']
+})
+export class SelectComponent implements OnInit, OnChanges {
+ @Input()
+ elemClass: string;
+ @Input()
+ data: Array<string> = [];
+ @Input()
+ options: Array<SelectOption> = [];
+ @Input()
+ messages = new SelectMessages({});
+ @Input()
+ selectionLimit: number;
+ @Input()
+ customBadges = false;
+ @Input()
+ customBadgeValidators: ValidatorFn[] = [];
+
+ @Output()
+ selection = new EventEmitter();
+
+ form: CdFormGroup;
+ filter: FormControl;
+ Object = Object;
+ filteredOptions: Array<SelectOption> = [];
+ icons = Icons;
+
+ ngOnInit() {
+ this.initFilter();
+ if (this.data.length > 0) {
+ this.initMissingOptions();
+ }
+ this.options = _.sortBy(this.options, ['name']);
+ this.updateOptions();
+ }
+
+ private initFilter() {
+ this.filter = new FormControl('', { validators: this.customBadgeValidators });
+ this.form = new CdFormGroup({ filter: this.filter });
+ this.filteredOptions = [...(this.options || [])];
+ }
+
+ private initMissingOptions() {
+ const options = this.options.map((option) => option.name);
+ const needToCreate = this.data.filter((option) => options.indexOf(option) === -1);
+ needToCreate.forEach((option) => this.addOption(option));
+ this.forceOptionsToReflectData();
+ }
+
+ private addOption(name: string) {
+ this.options.push(new SelectOption(false, name, ''));
+ this.options = _.sortBy(this.options, ['name']);
+ this.triggerSelection(this.options.find((option) => option.name === name));
+ }
+
+ triggerSelection(option: SelectOption) {
+ if (
+ !option ||
+ (this.selectionLimit && !option.selected && this.data.length >= this.selectionLimit)
+ ) {
+ return;
+ }
+ option.selected = !option.selected;
+ this.updateOptions();
+ this.selection.emit({ option: option });
+ }
+
+ private updateOptions() {
+ this.data.splice(0, this.data.length);
+ this.options.forEach((option: SelectOption) => {
+ if (option.selected) {
+ this.data.push(option.name);
+ }
+ });
+ this.updateFilter();
+ }
+
+ updateFilter() {
+ this.filteredOptions = this.options.filter((option) => option.name.includes(this.filter.value));
+ }
+
+ private forceOptionsToReflectData() {
+ this.options.forEach((option) => {
+ if (this.data.indexOf(option.name) !== -1) {
+ option.selected = true;
+ }
+ });
+ }
+
+ ngOnChanges() {
+ if (this.filter) {
+ this.updateFilter();
+ }
+ if (!this.options || !this.data || this.data.length === 0) {
+ return;
+ }
+ this.forceOptionsToReflectData();
+ }
+
+ selectOption() {
+ if (this.filteredOptions.length === 0) {
+ this.addCustomOption();
+ } else {
+ this.triggerSelection(this.filteredOptions[0]);
+ this.resetFilter();
+ }
+ }
+
+ addCustomOption() {
+ if (!this.isCreatable()) {
+ return;
+ }
+ this.addOption(this.filter.value);
+ this.resetFilter();
+ }
+
+ isCreatable() {
+ return (
+ this.customBadges &&
+ this.filter.valid &&
+ this.filter.value.length > 0 &&
+ this.filteredOptions.every((option) => option.name !== this.filter.value)
+ );
+ }
+
+ private resetFilter() {
+ this.filter.setValue('');
+ this.updateFilter();
+ }
+
+ removeItem(item: string) {
+ this.triggerSelection(
+ this.options.find((option: SelectOption) => option.name === item && option.selected)
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html
new file mode 100644
index 000000000..c823605d1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html
@@ -0,0 +1,15 @@
+<div class="chart-container"
+ [ngStyle]="style">
+ <canvas baseChart
+ #sparkCanvas
+ [labels]="labels"
+ [datasets]="datasets"
+ [options]="options"
+ [colors]="colors"
+ [chartType]="'line'">
+ </canvas>
+ <div class="chartjs-tooltip"
+ #sparkTooltip>
+ <table></table>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss
new file mode 100644
index 000000000..25486150b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss
@@ -0,0 +1,5 @@
+@use './src/styles/chart-tooltip';
+
+.chart-container {
+ position: static !important;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts
new file mode 100644
index 000000000..b8e731d6e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts
@@ -0,0 +1,52 @@
+import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SparklineComponent } from './sparkline.component';
+
+describe('SparklineComponent', () => {
+ let component: SparklineComponent;
+ let fixture: ComponentFixture<SparklineComponent>;
+
+ configureTestBed({
+ declarations: [SparklineComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [DimlessBinaryPipe, FormatterService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SparklineComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.options.tooltips.custom).toBeDefined();
+ });
+
+ it('should update', () => {
+ expect(component.datasets).toEqual([{ data: [] }]);
+ expect(component.labels.length).toBe(0);
+
+ component.data = [11, 22, 33];
+ component.ngOnChanges({ data: new SimpleChange(null, component.data, false) });
+
+ expect(component.datasets).toEqual([{ data: [11, 22, 33] }]);
+ expect(component.labels.length).toBe(3);
+ });
+
+ it('should not transform the label, if not isBinary', () => {
+ component.isBinary = false;
+ const result = component.options.tooltips.callbacks.label({ yLabel: 1024 });
+ expect(result).toBe(1024);
+ });
+
+ it('should transform the label, if isBinary', () => {
+ component.isBinary = true;
+ const result = component.options.tooltips.callbacks.label({ yLabel: 1024 });
+ expect(result).toBe('1 KiB');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts
new file mode 100644
index 000000000..e2f5af5e0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts
@@ -0,0 +1,130 @@
+import {
+ Component,
+ ElementRef,
+ Input,
+ OnChanges,
+ OnInit,
+ SimpleChanges,
+ ViewChild
+} from '@angular/core';
+
+import { ChartTooltip } from '~/app/shared/models/chart-tooltip';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+
+@Component({
+ selector: 'cd-sparkline',
+ templateUrl: './sparkline.component.html',
+ styleUrls: ['./sparkline.component.scss']
+})
+export class SparklineComponent implements OnInit, OnChanges {
+ @ViewChild('sparkCanvas', { static: true })
+ chartCanvasRef: ElementRef;
+ @ViewChild('sparkTooltip', { static: true })
+ chartTooltipRef: ElementRef;
+
+ @Input()
+ data: any;
+ @Input()
+ style = {
+ height: '30px',
+ width: '100px'
+ };
+ @Input()
+ isBinary: boolean;
+
+ public colors: Array<any> = [
+ {
+ backgroundColor: 'rgba(40,140,234,0.2)',
+ borderColor: 'rgba(40,140,234,1)',
+ pointBackgroundColor: 'rgba(40,140,234,1)',
+ pointBorderColor: '#fff',
+ pointHoverBackgroundColor: '#fff',
+ pointHoverBorderColor: 'rgba(40,140,234,0.8)'
+ }
+ ];
+
+ options: Record<string, any> = {
+ animation: {
+ duration: 0
+ },
+ responsive: true,
+ maintainAspectRatio: false,
+ legend: {
+ display: false
+ },
+ elements: {
+ line: {
+ borderWidth: 1
+ }
+ },
+ tooltips: {
+ enabled: false,
+ mode: 'index',
+ intersect: false,
+ custom: undefined,
+ callbacks: {
+ label: (tooltipItem: any) => {
+ if (this.isBinary) {
+ return this.dimlessBinaryPipe.transform(tooltipItem.yLabel);
+ } else {
+ return tooltipItem.yLabel;
+ }
+ },
+ title: () => ''
+ }
+ },
+ scales: {
+ yAxes: [
+ {
+ display: false
+ }
+ ],
+ xAxes: [
+ {
+ display: false
+ }
+ ]
+ }
+ };
+
+ public datasets: Array<any> = [
+ {
+ data: []
+ }
+ ];
+
+ public labels: Array<any> = [];
+
+ constructor(private dimlessBinaryPipe: DimlessBinaryPipe) {}
+
+ ngOnInit() {
+ const getStyleTop = (tooltip: any) => {
+ return tooltip.caretY - tooltip.height - tooltip.yPadding - 5 + 'px';
+ };
+
+ const getStyleLeft = (tooltip: any, positionX: number) => {
+ return positionX + tooltip.caretX + 'px';
+ };
+
+ const chartTooltip = new ChartTooltip(
+ this.chartCanvasRef,
+ this.chartTooltipRef,
+ getStyleLeft,
+ getStyleTop
+ );
+
+ chartTooltip.customColors = {
+ backgroundColor: this.colors[0].pointBackgroundColor,
+ borderColor: this.colors[0].pointBorderColor
+ };
+
+ this.options.tooltips.custom = (tooltip: any) => {
+ chartTooltip.customTooltips(tooltip);
+ };
+ }
+
+ ngOnChanges(changes: SimpleChanges) {
+ this.datasets[0].data = changes['data'].currentValue;
+ this.labels = [...Array(changes['data'].currentValue.length)];
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html
new file mode 100644
index 000000000..af557a293
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html
@@ -0,0 +1,11 @@
+<button [type]="type"
+ class="btn btn-accent tc_submitButton"
+ [ngClass]="btnClass"
+ [disabled]="loading || disabled"
+ (click)="submit($event)"
+ [attr.aria-label]="ariaLabel">
+ <ng-content></ng-content>
+ <span *ngIf="loading">
+ <i [ngClass]="[icons.spinner, icons.spin]"></i>
+ </span>
+</button>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts
new file mode 100644
index 000000000..a7b7023d0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormGroup } from '@angular/forms';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SubmitButtonComponent } from './submit-button.component';
+
+describe('SubmitButtonComponent', () => {
+ let component: SubmitButtonComponent;
+ let fixture: ComponentFixture<SubmitButtonComponent>;
+
+ configureTestBed({
+ declarations: [SubmitButtonComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SubmitButtonComponent);
+ component = fixture.componentInstance;
+
+ component.form = new FormGroup({}, {});
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts
new file mode 100644
index 000000000..3309f47ed
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts
@@ -0,0 +1,99 @@
+import { Component, ElementRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { AbstractControl, FormGroup, FormGroupDirective, NgForm } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+/**
+ * This component will render a submit button with the given label.
+ *
+ * The button will disabled itself and show a loading icon when the user clicks
+ * it, usually initiating a request to the server, and it will stay in that
+ * state until the request is finished.
+ *
+ * To indicate that the request failed, returning the button to the enable
+ * state, you need to insert an error in the form with the 'cdSubmitButton' key.
+ * p.e.: this.rbdForm.setErrors({'cdSubmitButton': true});
+ *
+ * It will also check if the form is valid, when clicking the button, and will
+ * focus on the first invalid input.
+ *
+ * @export
+ * @class SubmitButtonComponent
+ * @implements {OnInit}
+ */
+@Component({
+ selector: 'cd-submit-button',
+ templateUrl: './submit-button.component.html',
+ styleUrls: ['./submit-button.component.scss']
+})
+export class SubmitButtonComponent implements OnInit {
+ @Input()
+ form: FormGroup | NgForm;
+
+ @Input()
+ type = 'submit';
+
+ @Input()
+ disabled = false;
+
+ // A CSS class string to apply to the button's main element.
+ @Input()
+ btnClass: string;
+
+ @Input()
+ ariaLabel: string;
+
+ @Output()
+ submitAction = new EventEmitter();
+
+ loading = false;
+ icons = Icons;
+
+ constructor(private elRef: ElementRef) {}
+
+ ngOnInit() {
+ this.form.statusChanges.subscribe(() => {
+ if (_.has(this.form.errors, 'cdSubmitButton')) {
+ this.loading = false;
+ _.unset(this.form.errors, 'cdSubmitButton');
+ // Handle Reactive forms.
+ if (this.form instanceof AbstractControl) {
+ (<AbstractControl>this.form).updateValueAndValidity();
+ }
+ }
+ });
+ }
+
+ submit($event: any) {
+ this.focusButton();
+
+ // Special handling for Template driven forms.
+ if (this.form instanceof FormGroupDirective) {
+ (<FormGroupDirective>this.form).onSubmit($event);
+ }
+
+ if (this.form.invalid) {
+ this.focusInvalid();
+ return;
+ }
+
+ this.loading = true;
+ this.submitAction.emit();
+ }
+
+ focusButton() {
+ this.elRef.nativeElement.offsetParent.querySelector(`button[type="${this.type}"]`).focus();
+ }
+
+ focusInvalid() {
+ const target = this.elRef.nativeElement.offsetParent.querySelector(
+ 'input.ng-invalid, select.ng-invalid'
+ );
+
+ if (target) {
+ target.focus();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html
new file mode 100644
index 000000000..9af795837
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html
@@ -0,0 +1,12 @@
+<cd-alert-panel *ngIf="displayNotification"
+ class="no-margin-bottom"
+ [showTitle]="false"
+ size="slim"
+ [type]="notificationSeverity"
+ [dismissible]="notificationSeverity !== 'danger'"
+ (dismissed)="onDismissed()">
+ <div i18n>The Ceph community needs your help to continue improving: please
+ <a routerLink="/telemetry"
+ class="btn activate-button alert-link activate-text">Activate</a> the
+ <a href="https://docs.ceph.com/en/latest/mgr/telemetry/">Telemetry</a> module.</div>
+</cd-alert-panel>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss
new file mode 100644
index 000000000..cf8aa33d3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss
@@ -0,0 +1,17 @@
+@use './src/styles/vendor/variables' as vv;
+
+.no-margin-bottom {
+ margin-bottom: 0;
+}
+
+.activate-button {
+ background-color: vv.$barley-white;
+ border: vv.$gray-700 solid 0.5px;
+ border-radius: 10%;
+ padding: 0.1rem 0.4rem;
+}
+
+.activate-text {
+ color: vv.$gray-700;
+ font-weight: bold;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts
new file mode 100644
index 000000000..e946e79d8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts
@@ -0,0 +1,107 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { UserService } from '~/app/shared/api/user.service';
+import { AlertPanelComponent } from '~/app/shared/components/alert-panel/alert-panel.component';
+import { Permissions } from '~/app/shared/models/permissions';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TelemetryNotificationComponent } from './telemetry-notification.component';
+
+describe('TelemetryActivationNotificationComponent', () => {
+ let component: TelemetryNotificationComponent;
+ let fixture: ComponentFixture<TelemetryNotificationComponent>;
+
+ let authStorageService: AuthStorageService;
+ let mgrModuleService: MgrModuleService;
+ let notificationService: NotificationService;
+
+ let isNotificationHiddenSpy: jasmine.Spy;
+ let getPermissionsSpy: jasmine.Spy;
+ let getConfigSpy: jasmine.Spy;
+
+ const configOptPermissions: Permissions = new Permissions({
+ 'config-opt': ['read', 'create', 'update', 'delete']
+ });
+ const noConfigOptPermissions: Permissions = new Permissions({});
+ const telemetryEnabledConfig = {
+ enabled: true
+ };
+ const telemetryDisabledConfig = {
+ enabled: false
+ };
+
+ configureTestBed({
+ declarations: [TelemetryNotificationComponent, AlertPanelComponent],
+ imports: [NgbAlertModule, HttpClientTestingModule, ToastrModule.forRoot(), PipesModule],
+ providers: [MgrModuleService, UserService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TelemetryNotificationComponent);
+ component = fixture.componentInstance;
+ authStorageService = TestBed.inject(AuthStorageService);
+ mgrModuleService = TestBed.inject(MgrModuleService);
+ notificationService = TestBed.inject(NotificationService);
+
+ isNotificationHiddenSpy = spyOn(component, 'isNotificationHidden').and.returnValue(false);
+ getPermissionsSpy = spyOn(authStorageService, 'getPermissions').and.returnValue(
+ configOptPermissions
+ );
+ getConfigSpy = spyOn(mgrModuleService, 'getConfig').and.returnValue(
+ of(telemetryDisabledConfig)
+ );
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should not show notification again if the user closed it before', () => {
+ isNotificationHiddenSpy.and.returnValue(true);
+ fixture.detectChanges();
+ expect(component.displayNotification).toBe(false);
+ });
+
+ it('should not show notification for a user without configOpt permissions', () => {
+ getPermissionsSpy.and.returnValue(noConfigOptPermissions);
+ fixture.detectChanges();
+ expect(component.displayNotification).toBe(false);
+ });
+
+ it('should not show notification if the module is enabled already', () => {
+ getConfigSpy.and.returnValue(of(telemetryEnabledConfig));
+ fixture.detectChanges();
+ expect(component.displayNotification).toBe(false);
+ });
+
+ it('should show the notification if all pre-conditions set accordingly', () => {
+ fixture.detectChanges();
+ expect(component.displayNotification).toBe(true);
+ });
+
+ it('should hide the notification if the user closes it', () => {
+ spyOn(notificationService, 'show');
+ fixture.detectChanges();
+ component.onDismissed();
+ expect(notificationService.show).toHaveBeenCalled();
+ expect(localStorage.getItem('telemetry_notification_hidden')).toBe('true');
+ });
+
+ it('should hide the notification if the user logs out', () => {
+ const telemetryNotificationService = TestBed.inject(TelemetryNotificationService);
+ spyOn(telemetryNotificationService, 'setVisibility');
+ fixture.detectChanges();
+ component.ngOnDestroy();
+ expect(telemetryNotificationService.setVisibility).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts
new file mode 100644
index 000000000..33174ce11
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts
@@ -0,0 +1,62 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import _ from 'lodash';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service';
+
+@Component({
+ selector: 'cd-telemetry-notification',
+ templateUrl: './telemetry-notification.component.html',
+ styleUrls: ['./telemetry-notification.component.scss']
+})
+export class TelemetryNotificationComponent implements OnInit, OnDestroy {
+ displayNotification = false;
+ notificationSeverity = 'warning';
+
+ constructor(
+ private mgrModuleService: MgrModuleService,
+ private authStorageService: AuthStorageService,
+ private notificationService: NotificationService,
+ private telemetryNotificationService: TelemetryNotificationService
+ ) {}
+
+ ngOnInit() {
+ this.telemetryNotificationService.update.subscribe((visible: boolean) => {
+ this.displayNotification = visible;
+ });
+
+ if (!this.isNotificationHidden()) {
+ const configOptPermissions = this.authStorageService.getPermissions().configOpt;
+ if (_.every(Object.values(configOptPermissions))) {
+ this.mgrModuleService.getConfig('telemetry').subscribe((options) => {
+ if (!options['enabled']) {
+ this.telemetryNotificationService.setVisibility(true);
+ }
+ });
+ }
+ }
+ }
+
+ ngOnDestroy() {
+ this.telemetryNotificationService.setVisibility(false);
+ }
+
+ isNotificationHidden(): boolean {
+ return localStorage.getItem('telemetry_notification_hidden') === 'true';
+ }
+
+ onDismissed(): void {
+ this.telemetryNotificationService.setVisibility(false);
+ localStorage.setItem('telemetry_notification_hidden', 'true');
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Telemetry activation reminder muted`,
+ $localize`You can activate the module on the Telemetry configuration \
+page (<b>Dashboard Settings</b> -> <b>Telemetry configuration</b>) at any time.`
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html
new file mode 100644
index 000000000..0602a4e59
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html
@@ -0,0 +1,27 @@
+<ng-template #usageTooltipTpl>
+ <table>
+ <tr>
+ <td class="text-left">Used:&nbsp;</td>
+ <td class="text-right"><strong> {{ isBinary ? (used | dimlessBinary) : (used | dimless) }}</strong></td>
+ </tr>
+ <tr *ngIf="calculatePerc">
+ <td class="text-left">Free:&nbsp;</td>
+ <td class="'text-right"><strong>{{ isBinary ? (total - used | dimlessBinary) : (total - used | dimless) }}</strong></td>
+ </tr>
+ </table>
+</ng-template>
+
+<div class="progress"
+ data-placement="left"
+ [ngbTooltip]="usageTooltipTpl">
+ <div class="progress-bar bg-info"
+ [ngClass]="{'bg-warning': usedPercentage/100 >= warningThreshold, 'bg-danger': usedPercentage/100 >= errorThreshold}"
+ role="progressbar"
+ [style.width]="usedPercentage + '%'">
+ <span>{{ usedPercentage | number: '1.0-' + decimals }}%</span>
+ </div>
+ <div class="progress-bar bg-freespace"
+ role="progressbar"
+ [style.width]="freePercentage + '%'">
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss
new file mode 100644
index 000000000..e9d6d2498
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss
@@ -0,0 +1,35 @@
+@use './src/styles/vendor/variables' as vv;
+
+.bg-info {
+ background-color: vv.$primary !important;
+}
+
+.bg-warning {
+ background-color: vv.$warning !important;
+}
+
+.bg-danger {
+ background-color: vv.$danger !important;
+}
+
+.bg-freespace {
+ background-color: vv.$gray-400 !important;
+}
+
+.progress {
+ height: 20px;
+ margin-bottom: 0;
+ position: relative;
+
+ div.progress-bar {
+ position: static;
+ }
+
+ span {
+ color: vv.$black;
+ display: block;
+ font-weight: normal;
+ position: absolute;
+ width: 100%;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts
new file mode 100644
index 000000000..45e6a06b6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { UsageBarComponent } from './usage-bar.component';
+
+describe('UsageBarComponent', () => {
+ let component: UsageBarComponent;
+ let fixture: ComponentFixture<UsageBarComponent>;
+
+ configureTestBed({
+ imports: [PipesModule, NgbTooltipModule],
+ declarations: [UsageBarComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(UsageBarComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts
new file mode 100644
index 000000000..203f2c9e0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts
@@ -0,0 +1,43 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import _ from 'lodash';
+
+@Component({
+ selector: 'cd-usage-bar',
+ templateUrl: './usage-bar.component.html',
+ styleUrls: ['./usage-bar.component.scss']
+})
+export class UsageBarComponent implements OnChanges {
+ @Input()
+ total: number;
+ @Input()
+ used: any;
+ @Input()
+ warningThreshold: number;
+ @Input()
+ errorThreshold: number;
+ @Input()
+ isBinary = true;
+ @Input()
+ decimals = 0;
+ @Input()
+ calculatePerc = true;
+
+ usedPercentage: number;
+ freePercentage: number;
+
+ ngOnChanges() {
+ if (this.calculatePerc) {
+ this.usedPercentage = this.total > 0 ? (this.used / this.total) * 100 : 0;
+ this.freePercentage = 100 - this.usedPercentage;
+ } else {
+ if (this.used) {
+ this.used = this.used.slice(0, -1);
+ this.usedPercentage = Number(this.used);
+ this.freePercentage = 100 - this.usedPercentage;
+ } else {
+ this.usedPercentage = 0;
+ }
+ }
+ }
+}
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 000000000..25aa3e1df
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html
@@ -0,0 +1,19 @@
+<div class="card-body">
+ <div class="row m-7">
+ <nav class="col">
+ <ul class="nav nav-pills flex-column"
+ *ngFor="let step of steps | async; let i = index;">
+ <li class="nav-item">
+ <a class="nav-link"
+ (click)="onStepClick(step)"
+ [ngClass]="{active: currentStep.stepIndex === step.stepIndex}">
+ <span class="circle-step"
+ [ngClass]="{active: currentStep.stepIndex === step.stepIndex}"
+ i18n>{{ step.stepIndex }}</span>
+ <span i18n>{{ stepsTitle[i] }}</span>
+ </a>
+ </li>
+ </ul>
+ </nav>
+ </div>
+</div>
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 000000000..071b02e4a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss
@@ -0,0 +1,34 @@
+@use './src/styles/vendor/variables' as vv;
+
+::ng-deep cd-wizard {
+ width: 15%;
+}
+
+.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 000000000..b42578fb7
--- /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<WizardComponent>;
+
+ 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 000000000..d46aa480e
--- /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<WizardStepModel[]>;
+ 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/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
new file mode 100644
index 000000000..4248be8f5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
@@ -0,0 +1,305 @@
+import { Injectable } from '@angular/core';
+
+import { environment } from '~/environments/environment';
+
+export class AppConstants {
+ public static readonly organization = 'ceph';
+ public static readonly projectName = 'Ceph Dashboard';
+ public static readonly license = 'Free software (LGPL 2.1).';
+ public static readonly copyright = 'Copyright(c) ' + environment.year + ' Ceph contributors.';
+ public static readonly cephLogo = 'assets/Ceph_Logo.svg';
+}
+
+export enum URLVerbs {
+ /* Create a new item */
+ CREATE = 'create',
+
+ /* Make changes to an existing item */
+ EDIT = 'edit',
+
+ /* Make changes to an existing item */
+ UPDATE = 'update',
+
+ /* Remove an item from a container WITHOUT deleting it */
+ REMOVE = 'remove',
+
+ /* Destroy an existing item */
+ DELETE = 'delete',
+
+ /* Add an existing item to a container */
+ ADD = 'add',
+
+ /* Non-standard verbs */
+ COPY = 'copy',
+ CLONE = 'clone',
+
+ /* Prometheus wording */
+ RECREATE = 'recreate',
+ EXPIRE = 'expire',
+
+ /* Daemons */
+ RESTART = 'Restart'
+}
+
+export enum ActionLabels {
+ /* Create a new item */
+ CREATE = 'Create',
+
+ /* Destroy an existing item */
+ DELETE = 'Delete',
+
+ /* Add an existing item to a container */
+ ADD = 'Add',
+
+ /* Remove an item from a container WITHOUT deleting it */
+ REMOVE = 'Remove',
+
+ /* Make changes to an existing item */
+ EDIT = 'Edit',
+
+ /* */
+ CANCEL = 'Cancel',
+
+ /* Non-standard actions */
+ COPY = 'Copy',
+ CLONE = 'Clone',
+ UPDATE = 'Update',
+ EVICT = 'Evict',
+
+ /* Read-only */
+ SHOW = 'Show',
+
+ /* Prometheus wording */
+ RECREATE = 'Recreate',
+ EXPIRE = 'Expire',
+
+ /* Daemons */
+ START = 'Start',
+ STOP = 'Stop',
+ REDEPLOY = 'Redeploy',
+ RESTART = 'Restart'
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ActionLabelsI18n {
+ /* This service is required as the i18n polyfill does not provide static
+ translation
+ */
+ CREATE: string;
+ DELETE: string;
+ ADD: string;
+ REMOVE: string;
+ EDIT: string;
+ CANCEL: string;
+ PREVIEW: string;
+ MOVE: string;
+ NEXT: string;
+ BACK: string;
+ CHANGE: string;
+ COPY: string;
+ CLONE: string;
+ DEEP_SCRUB: string;
+ DESTROY: string;
+ EVICT: string;
+ EXPIRE: string;
+ FLATTEN: string;
+ MARK_DOWN: string;
+ MARK_IN: string;
+ MARK_LOST: string;
+ MARK_OUT: string;
+ PROTECT: string;
+ PURGE: string;
+ RECREATE: string;
+ RENAME: string;
+ RESTORE: string;
+ REWEIGHT: string;
+ ROLLBACK: string;
+ SCRUB: string;
+ SET: string;
+ SUBMIT: string;
+ SHOW: string;
+ TRASH: string;
+ UNPROTECT: string;
+ UNSET: string;
+ UPDATE: string;
+ FLAGS: string;
+ ENTER_MAINTENANCE: string;
+ EXIT_MAINTENANCE: string;
+ REMOVE_SCHEDULING: string;
+ PROMOTE: string;
+ DEMOTE: string;
+ START_DRAIN: string;
+ STOP_DRAIN: string;
+ START: string;
+ STOP: string;
+ REDEPLOY: string;
+ RESTART: string;
+ RESYNC: string;
+
+ constructor() {
+ /* Create a new item */
+ this.CREATE = $localize`Create`;
+
+ /* Destroy an existing item */
+ this.DELETE = $localize`Delete`;
+
+ /* Add an existing item to a container */
+ this.ADD = $localize`Add`;
+ this.SET = $localize`Set`;
+ this.SUBMIT = $localize`Submit`;
+
+ /* Remove an item from a container WITHOUT deleting it */
+ this.REMOVE = $localize`Remove`;
+ this.UNSET = $localize`Unset`;
+
+ /* Make changes to an existing item */
+ this.EDIT = $localize`Edit`;
+ this.UPDATE = $localize`Update`;
+ this.CANCEL = $localize`Cancel`;
+ this.PREVIEW = $localize`Preview`;
+ this.MOVE = $localize`Move`;
+
+ /* Wizard wording */
+ this.NEXT = $localize`Next`;
+ this.BACK = $localize`Back`;
+
+ /* Non-standard actions */
+ this.CLONE = $localize`Clone`;
+ this.COPY = $localize`Copy`;
+ this.DEEP_SCRUB = $localize`Deep Scrub`;
+ this.DESTROY = $localize`Destroy`;
+ this.EVICT = $localize`Evict`;
+ this.FLATTEN = $localize`Flatten`;
+ this.MARK_DOWN = $localize`Mark Down`;
+ this.MARK_IN = $localize`Mark In`;
+ this.MARK_LOST = $localize`Mark Lost`;
+ this.MARK_OUT = $localize`Mark Out`;
+ this.PROTECT = $localize`Protect`;
+ this.PURGE = $localize`Purge`;
+ this.RENAME = $localize`Rename`;
+ this.RESTORE = $localize`Restore`;
+ this.REWEIGHT = $localize`Reweight`;
+ this.ROLLBACK = $localize`Rollback`;
+ this.SCRUB = $localize`Scrub`;
+ this.SHOW = $localize`Show`;
+ this.TRASH = $localize`Move to Trash`;
+ this.UNPROTECT = $localize`Unprotect`;
+ this.CHANGE = $localize`Change`;
+ this.FLAGS = $localize`Flags`;
+ this.ENTER_MAINTENANCE = $localize`Enter Maintenance`;
+ this.EXIT_MAINTENANCE = $localize`Exit Maintenance`;
+
+ this.START_DRAIN = $localize`Start Drain`;
+ this.STOP_DRAIN = $localize`Stop Drain`;
+ this.RESYNC = $localize`Resync`;
+ /* Prometheus wording */
+ this.RECREATE = $localize`Recreate`;
+ this.EXPIRE = $localize`Expire`;
+
+ this.START = $localize`Start`;
+ this.STOP = $localize`Stop`;
+ this.REDEPLOY = $localize`Redeploy`;
+ this.RESTART = $localize`Restart`;
+
+ this.REMOVE_SCHEDULING = $localize`Remove Scheduling`;
+ this.PROMOTE = $localize`Promote`;
+ this.DEMOTE = $localize`Demote`;
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class SucceededActionLabelsI18n {
+ /* This service is required as the i18n polyfill does not provide static
+ translation
+ */
+ CREATED: string;
+ DELETED: string;
+ ADDED: string;
+ REMOVED: string;
+ EDITED: string;
+ CANCELED: string;
+ PREVIEWED: string;
+ MOVED: string;
+ COPIED: string;
+ CLONED: string;
+ DEEP_SCRUBBED: string;
+ DESTROYED: string;
+ FLATTENED: string;
+ MARKED_DOWN: string;
+ MARKED_IN: string;
+ MARKED_LOST: string;
+ MARKED_OUT: string;
+ PROTECTED: string;
+ PURGED: string;
+ RENAMED: string;
+ RESTORED: string;
+ REWEIGHTED: string;
+ ROLLED_BACK: string;
+ SCRUBBED: string;
+ SHOWED: string;
+ TRASHED: string;
+ UNPROTECTED: string;
+ CHANGE: string;
+ RECREATED: string;
+ EXPIRED: string;
+ MOVE: string;
+ START: string;
+ STOP: string;
+ REDEPLOY: string;
+ RESTART: string;
+
+ constructor() {
+ /* Create a new item */
+ this.CREATED = $localize`Created`;
+
+ /* Destroy an existing item */
+ this.DELETED = $localize`Deleted`;
+
+ /* Add an existing item to a container */
+ this.ADDED = $localize`Added`;
+
+ /* Remove an item from a container WITHOUT deleting it */
+ this.REMOVED = $localize`Removed`;
+
+ /* Make changes to an existing item */
+ this.EDITED = $localize`Edited`;
+ this.CANCELED = $localize`Canceled`;
+ this.PREVIEWED = $localize`Previewed`;
+ this.MOVED = $localize`Moved`;
+
+ /* Non-standard actions */
+ this.CLONED = $localize`Cloned`;
+ this.COPIED = $localize`Copied`;
+ this.DEEP_SCRUBBED = $localize`Deep Scrubbed`;
+ this.DESTROYED = $localize`Destroyed`;
+ this.FLATTENED = $localize`Flattened`;
+ this.MARKED_DOWN = $localize`Marked Down`;
+ this.MARKED_IN = $localize`Marked In`;
+ this.MARKED_LOST = $localize`Marked Lost`;
+ this.MARKED_OUT = $localize`Marked Out`;
+ this.PROTECTED = $localize`Protected`;
+ this.PURGED = $localize`Purged`;
+ this.RENAMED = $localize`Renamed`;
+ this.RESTORED = $localize`Restored`;
+ this.REWEIGHTED = $localize`Reweighted`;
+ this.ROLLED_BACK = $localize`Rolled back`;
+ this.SCRUBBED = $localize`Scrubbed`;
+ this.SHOWED = $localize`Showed`;
+ this.TRASHED = $localize`Moved to Trash`;
+ this.UNPROTECTED = $localize`Unprotected`;
+ this.CHANGE = $localize`Change`;
+
+ /* Prometheus wording */
+ this.RECREATED = $localize`Recreated`;
+ this.EXPIRED = $localize`Expired`;
+
+ this.START = $localize`Start`;
+ this.STOP = $localize`Stop`;
+ this.REDEPLOY = $localize`Redeploy`;
+ this.RESTART = $localize`Restart`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts
new file mode 100644
index 000000000..ede8f2368
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts
@@ -0,0 +1,31 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ComponentsModule } from '../components/components.module';
+import { PipesModule } from '../pipes/pipes.module';
+import { TableActionsComponent } from './table-actions/table-actions.component';
+import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
+import { TableComponent } from './table/table.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ NgxDatatableModule,
+ NgxPipeFunctionModule,
+ FormsModule,
+ NgbDropdownModule,
+ NgbTooltipModule,
+ PipesModule,
+ ComponentsModule,
+ RouterModule
+ ],
+ declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent],
+ exports: [TableComponent, NgxDatatableModule, TableKeyValueComponent, TableActionsComponent]
+})
+export class DataTableModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html
new file mode 100644
index 000000000..905aaa96b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html
@@ -0,0 +1,43 @@
+<div class="btn-group">
+ <ng-container *ngIf="currentAction">
+ <button type="button"
+ title="{{ useDisableDesc(currentAction) }}"
+ class="btn btn-{{btnColor}}"
+ [ngClass]="{'disabled': disableSelectionAction(currentAction)}"
+ (click)="useClickAction(currentAction)"
+ [routerLink]="useRouterLink(currentAction)"
+ [attr.aria-label]="currentAction.name"
+ [preserveFragment]="currentAction.preserveFragment ? '' : null">
+ <i [ngClass]="[currentAction.icon]"></i>
+ <span>{{ currentAction.name }}</span>
+ </button>
+ </ng-container>
+ <div class="btn-group"
+ ngbDropdown
+ role="group"
+ *ngIf="dropDownActions.length > 1"
+ aria-label="Button group with nested dropdown">
+ <button class="btn btn-{{btnColor}} dropdown-toggle-split"
+ ngbDropdownToggle>
+ <ng-container *ngIf="dropDownOnly">{{ dropDownOnly }} </ng-container>
+ <span *ngIf="!dropDownOnly"
+ class="sr-only"></span>
+ </button>
+ <div class="dropdown-menu"
+ ngbDropdownMenu>
+ <ng-container *ngFor="let action of dropDownActions">
+ <button ngbDropdownItem
+ class="{{ toClassName(action) }}"
+ title="{{ useDisableDesc(action) }}"
+ (click)="useClickAction(action)"
+ [routerLink]="useRouterLink(action)"
+ [preserveFragment]="action.preserveFragment ? '' : null"
+ [disabled]="disableSelectionAction(action)"
+ [attr.aria-label]="action.name">
+ <i [ngClass]="[action.icon, 'action-icon']"></i>
+ <span>{{ action.name }}</span>
+ </button>
+ </ng-container>
+ </div>
+ </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss
new file mode 100644
index 000000000..f996de727
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss
@@ -0,0 +1,8 @@
+button.disabled {
+ cursor: default !important;
+ pointer-events: auto;
+}
+
+.action-icon {
+ padding-right: 1.5rem;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts
new file mode 100644
index 000000000..81cc1b972
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts
@@ -0,0 +1,213 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper';
+import { TableActionsComponent } from './table-actions.component';
+
+describe('TableActionsComponent', () => {
+ let component: TableActionsComponent;
+ let fixture: ComponentFixture<TableActionsComponent>;
+ let addAction: CdTableAction;
+ let editAction: CdTableAction;
+ let protectAction: CdTableAction;
+ let unprotectAction: CdTableAction;
+ let deleteAction: CdTableAction;
+ let copyAction: CdTableAction;
+ let permissionHelper: PermissionHelper;
+
+ configureTestBed({
+ declarations: [TableActionsComponent],
+ imports: [ComponentsModule, NgxPipeFunctionModule, RouterTestingModule]
+ });
+
+ beforeEach(() => {
+ addAction = {
+ permission: 'create',
+ icon: 'fa-plus',
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ name: 'Add'
+ };
+ editAction = {
+ permission: 'update',
+ icon: 'fa-pencil',
+ name: 'Edit'
+ };
+ copyAction = {
+ permission: 'create',
+ icon: 'fa-copy',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection,
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection || selection.first().cdExecuting,
+ name: 'Copy'
+ };
+ deleteAction = {
+ permission: 'delete',
+ icon: 'fa-times',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSelection,
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSelection || selection.first().cdExecuting,
+ name: 'Delete'
+ };
+ protectAction = {
+ permission: 'update',
+ icon: 'fa-lock',
+ canBePrimary: () => false,
+ visible: (selection: CdTableSelection) => selection.hasSingleSelection,
+ name: 'Protect'
+ };
+ unprotectAction = {
+ permission: 'update',
+ icon: 'fa-unlock',
+ canBePrimary: () => false,
+ visible: (selection: CdTableSelection) => !selection.hasSingleSelection,
+ name: 'Unprotect'
+ };
+ fixture = TestBed.createComponent(TableActionsComponent);
+ component = fixture.componentInstance;
+ component.selection = new CdTableSelection();
+ component.permission = new Permission();
+ component.permission.read = true;
+ component.tableActions = [
+ addAction,
+ editAction,
+ protectAction,
+ unprotectAction,
+ copyAction,
+ deleteAction
+ ];
+ permissionHelper = new PermissionHelper(component.permission);
+ permissionHelper.setPermissionsAndGetActions(component.tableActions);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should call ngInit without permissions', () => {
+ component.permission = undefined;
+ component.ngOnInit();
+ expect(component.tableActions).toEqual([]);
+ expect(component.dropDownActions).toEqual([]);
+ });
+
+ describe('useRouterLink', () => {
+ const testLink = '/api/some/link';
+ it('should use a link generated from a function', () => {
+ addAction.routerLink = () => testLink;
+ expect(component.useRouterLink(addAction)).toBe(testLink);
+ });
+
+ it('should use the link as it is because it is a string', () => {
+ addAction.routerLink = testLink;
+ expect(component.useRouterLink(addAction)).toBe(testLink);
+ });
+
+ it('should not return anything because no link is defined', () => {
+ expect(component.useRouterLink(addAction)).toBe(undefined);
+ });
+
+ it('should not return anything because the action is disabled', () => {
+ editAction.routerLink = testLink;
+ expect(component.useRouterLink(editAction)).toBe(undefined);
+ });
+ });
+
+ it('should test all TableActions combinations', () => {
+ const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions(
+ component.tableActions
+ );
+ expect(tableActions).toEqual({
+ 'create,update,delete': {
+ actions: ['Add', 'Edit', 'Protect', 'Unprotect', 'Copy', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Add' }
+ },
+ 'create,update': {
+ actions: ['Add', 'Edit', 'Protect', 'Unprotect', 'Copy'],
+ primary: { multiple: 'Add', executing: 'Edit', single: 'Edit', no: 'Add' }
+ },
+ 'create,delete': {
+ actions: ['Add', 'Copy', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Copy', single: 'Copy', no: 'Add' }
+ },
+ create: {
+ actions: ['Add', 'Copy'],
+ primary: { multiple: 'Add', executing: 'Copy', single: 'Copy', no: 'Add' }
+ },
+ 'update,delete': {
+ actions: ['Edit', 'Protect', 'Unprotect', 'Delete'],
+ primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ update: {
+ actions: ['Edit', 'Protect', 'Unprotect'],
+ primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' }
+ },
+ delete: {
+ actions: ['Delete'],
+ primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+ },
+ 'no-permissions': {
+ actions: [],
+ primary: { multiple: '', executing: '', single: '', no: '' }
+ }
+ });
+ });
+
+ it('should convert any name to a proper CSS class', () => {
+ expect(component.toClassName({ name: 'Create' } as CdTableAction)).toBe('create');
+ expect(component.toClassName({ name: 'Mark x down' } as CdTableAction)).toBe('mark-x-down');
+ expect(component.toClassName({ name: '?Su*per!' } as CdTableAction)).toBe('super');
+ });
+
+ describe('useDisableDesc', () => {
+ it('should return a description if disable method returns a string', () => {
+ const deleteWithDescAction: CdTableAction = {
+ permission: 'delete',
+ icon: 'fa-times',
+ canBePrimary: (selection: CdTableSelection) => selection.hasSelection,
+ disable: () => {
+ return 'Delete action disabled description';
+ },
+ name: 'DeleteDesc'
+ };
+
+ expect(component.useDisableDesc(deleteWithDescAction)).toBe(
+ 'Delete action disabled description'
+ );
+ });
+
+ it('should return no description if disable does not return string', () => {
+ expect(component.useDisableDesc(deleteAction)).toBeUndefined();
+ });
+ });
+
+ describe('useClickAction', () => {
+ const editClickAction: CdTableAction = {
+ permission: 'update',
+ icon: 'fa-pencil',
+ name: 'Edit',
+ click: () => {
+ return 'Edit action click';
+ }
+ };
+
+ it('should call click action if action is not disabled', () => {
+ editClickAction.disable = () => {
+ return false;
+ };
+ expect(component.useClickAction(editClickAction)).toBe('Edit action click');
+ });
+
+ it('should not call click action if action is disabled', () => {
+ editClickAction.disable = () => {
+ return true;
+ };
+ expect(component.useClickAction(editClickAction)).toBeFalsy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts
new file mode 100644
index 000000000..0497f9301
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts
@@ -0,0 +1,161 @@
+import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+
+import _ from 'lodash';
+
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+
+@Component({
+ selector: 'cd-table-actions',
+ templateUrl: './table-actions.component.html',
+ styleUrls: ['./table-actions.component.scss']
+})
+export class TableActionsComponent implements OnChanges, OnInit {
+ @Input()
+ permission: Permission;
+ @Input()
+ selection: CdTableSelection;
+ @Input()
+ tableActions: CdTableAction[];
+ @Input()
+ btnColor = 'accent';
+
+ // Use this if you just want to display a drop down button,
+ // labeled with the given text, with all actions in it.
+ // This disables the main action button.
+ @Input()
+ dropDownOnly?: string;
+
+ currentAction?: CdTableAction;
+ // Array with all visible actions
+ dropDownActions: CdTableAction[] = [];
+
+ icons = Icons;
+
+ ngOnInit() {
+ this.removeActionsWithNoPermissions();
+ this.onSelectionChange();
+ }
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes.selection) {
+ this.onSelectionChange();
+ }
+ }
+
+ onSelectionChange(): void {
+ this.updateDropDownActions();
+ this.updateCurrentAction();
+ }
+
+ toClassName(action: CdTableAction): string {
+ return action.name
+ .replace(/ /g, '-')
+ .replace(/[^a-z-]/gi, '')
+ .toLowerCase();
+ }
+
+ /**
+ * Removes all actions from 'tableActions' that need a permission the user doesn't have.
+ */
+ private removeActionsWithNoPermissions() {
+ if (!this.permission) {
+ this.tableActions = [];
+ return;
+ }
+ const permissions = Object.keys(this.permission).filter((key) => this.permission[key]);
+ this.tableActions = this.tableActions.filter((action) =>
+ permissions.includes(action.permission)
+ );
+ }
+
+ private updateDropDownActions(): void {
+ this.dropDownActions = this.tableActions.filter((action) =>
+ action.visible ? action.visible(this.selection) : action
+ );
+ }
+
+ /**
+ * Finds the next action that is used as main action for the button
+ *
+ * The order of the list is crucial to get the right main action.
+ *
+ * Default button conditions of actions:
+ * - 'create' actions can be used with no or multiple selections
+ * - 'update' and 'delete' actions can be used with one selection
+ */
+ private updateCurrentAction(): void {
+ if (this.dropDownOnly) {
+ this.currentAction = undefined;
+ return;
+ }
+ let buttonAction = this.dropDownActions.find((tableAction) => this.showableAction(tableAction));
+ if (!buttonAction && this.dropDownActions.length > 0) {
+ buttonAction = this.dropDownActions[0];
+ }
+ this.currentAction = buttonAction;
+ }
+
+ /**
+ * Determines if action can be used for the button
+ *
+ * @param {CdTableAction} action
+ * @returns {boolean}
+ */
+ private showableAction(action: CdTableAction): boolean {
+ const condition = action.canBePrimary;
+ const singleSelection = this.selection.hasSingleSelection;
+ const defaultCase = action.permission === 'create' ? !singleSelection : singleSelection;
+ return (condition && condition(this.selection)) || (!condition && defaultCase);
+ }
+
+ useRouterLink(action: CdTableAction): string {
+ if (!action.routerLink || this.disableSelectionAction(action)) {
+ return undefined;
+ }
+ return _.isString(action.routerLink) ? action.routerLink : action.routerLink();
+ }
+
+ /**
+ * Determines if an action should be disabled
+ *
+ * Default disable conditions of 'update' and 'delete' actions:
+ * - If no or multiple selections are made
+ * - If one selection is made, but a task is executed on that item
+ *
+ * @param {CdTableAction} action
+ * @returns {Boolean}
+ */
+ disableSelectionAction(action: CdTableAction): Boolean {
+ const disable = action.disable;
+ if (disable) {
+ return Boolean(disable(this.selection));
+ }
+ const permission = action.permission;
+ const selected = this.selection.hasSingleSelection && this.selection.first();
+ return Boolean(
+ ['update', 'delete'].includes(permission) && (!selected || selected.cdExecuting)
+ );
+ }
+
+ useClickAction(action: CdTableAction) {
+ /**
+ * In order to show tooltips for deactivated menu items, the class
+ * 'pointer-events: auto;' has been added to the .scss file which also
+ * re-activates the click-event.
+ * To prevent calling the click-event on deactivated elements we also have
+ * to check here if it's disabled.
+ */
+ return !this.disableSelectionAction(action) && action.click && action.click();
+ }
+
+ useDisableDesc(action: CdTableAction) {
+ if (action.disable) {
+ const result = action.disable(this.selection);
+ return _.isString(result) ? result : undefined;
+ }
+ return undefined;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html
new file mode 100644
index 000000000..2e2dda4e9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html
@@ -0,0 +1,12 @@
+<cd-table #table
+ [data]="tableData"
+ [columns]="columns"
+ columnMode="flex"
+ [toolHeader]="false"
+ [autoReload]="autoReload"
+ [customCss]="customCss"
+ [autoSave]="false"
+ [header]="false"
+ [footer]="false"
+ [limit]="0">
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts
new file mode 100644
index 000000000..150d44241
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts
@@ -0,0 +1,351 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TableComponent } from '../table/table.component';
+import { TableKeyValueComponent } from './table-key-value.component';
+
+describe('TableKeyValueComponent', () => {
+ let component: TableKeyValueComponent;
+ let fixture: ComponentFixture<TableKeyValueComponent>;
+
+ configureTestBed({
+ declarations: [TableComponent, TableKeyValueComponent],
+ imports: [
+ FormsModule,
+ NgxDatatableModule,
+ ComponentsModule,
+ RouterTestingModule,
+ NgbDropdownModule,
+ PipesModule,
+ NgbTooltipModule,
+ NgxPipeFunctionModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TableKeyValueComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should make key value object pairs out of arrays with length two', () => {
+ component.data = [
+ ['someKey', 0],
+ ['arrayKey', [1, 2, 3]],
+ [3, 'something']
+ ];
+ component.ngOnInit();
+ const expected: any = [
+ { key: 'arrayKey', value: '1, 2, 3' },
+ { key: 'someKey', value: 0 },
+ { key: 3, value: 'something' }
+ ];
+ expect(component.tableData).toEqual(expected);
+ });
+
+ it('should not show data supposed to be have hidden by key', () => {
+ component.data = [
+ ['a', 1],
+ ['b', 2]
+ ];
+ component.hideKeys = ['a'];
+ component.ngOnInit();
+ expect(component.tableData).toEqual([{ key: 'b', value: 2 }]);
+ });
+
+ it('should remove items with objects as values', () => {
+ component.data = [
+ [3, 'something'],
+ ['will be removed', { a: 3, b: 4, c: 5 }]
+ ];
+ component.ngOnInit();
+ expect(component.tableData).toEqual(<any>[{ key: 3, value: 'something' }]);
+ });
+
+ it('makes key value object pairs out of an object', () => {
+ component.data = { 3: 'something', someKey: 0 };
+ component.ngOnInit();
+ expect(component.tableData).toEqual([
+ { key: '3', value: 'something' },
+ { key: 'someKey', value: 0 }
+ ]);
+ });
+
+ it('does nothing if data does not need to be converted', () => {
+ component.data = [
+ { key: 3, value: 'something' },
+ { key: 'someKey', value: 0 }
+ ];
+ component.ngOnInit();
+ expect(component.tableData).toEqual(component.data);
+ });
+
+ it('throws errors if data cannot be converted', () => {
+ component.data = 38;
+ expect(() => component.ngOnInit()).toThrowError('Wrong data format');
+ component.data = [['someKey', 0, 3]];
+ expect(() => component.ngOnInit()).toThrowError(
+ 'Array contains too many elements (3). Needs to be of type [string, any][]'
+ );
+ });
+
+ it('tests makePairs()', () => {
+ const makePairs = (data: any) => component['makePairs'](data);
+ expect(makePairs([['dash', 'board']])).toEqual([{ key: 'dash', value: 'board' }]);
+ const pair = [
+ { key: 'dash', value: 'board' },
+ { key: 'ceph', value: 'mimic' }
+ ];
+ const pairInverse = [
+ { key: 'ceph', value: 'mimic' },
+ { key: 'dash', value: 'board' }
+ ];
+ expect(makePairs(pair)).toEqual(pairInverse);
+ expect(makePairs({ dash: 'board' })).toEqual([{ key: 'dash', value: 'board' }]);
+ expect(makePairs({ dash: 'board', ceph: 'mimic' })).toEqual(pairInverse);
+ });
+
+ it('tests makePairsFromArray()', () => {
+ const makePairsFromArray = (data: any[]) => component['makePairsFromArray'](data);
+ expect(makePairsFromArray([['dash', 'board']])).toEqual([{ key: 'dash', value: 'board' }]);
+ const pair = [
+ { key: 'dash', value: 'board' },
+ { key: 'ceph', value: 'mimic' }
+ ];
+ expect(makePairsFromArray(pair)).toEqual(pair);
+ });
+
+ it('tests makePairsFromObject()', () => {
+ const makePairsFromObject = (data: object) => component['makePairsFromObject'](data);
+ expect(makePairsFromObject({ dash: 'board' })).toEqual([{ key: 'dash', value: 'board' }]);
+ expect(makePairsFromObject({ dash: 'board', ceph: 'mimic' })).toEqual([
+ { key: 'dash', value: 'board' },
+ { key: 'ceph', value: 'mimic' }
+ ]);
+ });
+
+ describe('tests convertValue()', () => {
+ const convertValue = (data: any) => component['convertValue'](data);
+ const expectConvertValue = (value: any, expectation: any) =>
+ expect(convertValue(value)).toBe(expectation);
+
+ it('should not convert strings', () => {
+ expectConvertValue('something', 'something');
+ });
+
+ it('should not convert integers', () => {
+ expectConvertValue(29, 29);
+ });
+
+ it('should convert arrays with any type to strings', () => {
+ expectConvertValue([1, 2, 3], '1, 2, 3');
+ expectConvertValue([{ sth: 'something' }], '{"sth":"something"}');
+ expectConvertValue([1, 'two', { 3: 'three' }], '1, two, {"3":"three"}');
+ });
+
+ it('should only convert objects if renderObjects is set to true', () => {
+ expect(convertValue({ sth: 'something' })).toBe(null);
+ component.renderObjects = true;
+ expect(convertValue({ sth: 'something' })).toEqual({ sth: 'something' });
+ });
+ });
+
+ describe('automatically pipe UTC dates through cdDate', () => {
+ let datePipe: CdDatePipe;
+
+ beforeEach(() => {
+ datePipe = TestBed.inject(CdDatePipe);
+ spyOn(datePipe, 'transform').and.callThrough();
+ });
+
+ const expectTimeConversion = (date: string) => {
+ component.data = { dateKey: date };
+ component.ngOnInit();
+ expect(datePipe.transform).toHaveBeenCalledWith(date);
+ expect(component.tableData[0].key).not.toBe(date);
+ };
+
+ it('converts some date', () => {
+ expectTimeConversion('2019-04-15 12:26:52.305285');
+ });
+
+ it('converts UTC date', () => {
+ expectTimeConversion('2019-04-16T12:35:46.646300974Z');
+ });
+ });
+
+ describe('render objects', () => {
+ beforeEach(() => {
+ component.data = {
+ options: {
+ numberKey: 38,
+ stringKey: 'somethingElse',
+ objectKey: {
+ sub1: 12,
+ sub2: 34,
+ sub3: 56
+ }
+ },
+ otherOptions: {
+ sub1: {
+ x: 42
+ },
+ sub2: {
+ y: 555
+ }
+ },
+ additionalKeyContainingObject: { type: 'none' },
+ keyWithEmptyObject: {}
+ };
+ component.renderObjects = true;
+ });
+
+ it('with parent key', () => {
+ component.ngOnInit();
+ expect(component.tableData).toEqual([
+ { key: 'additionalKeyContainingObject type', value: 'none' },
+ { key: 'keyWithEmptyObject', value: '' },
+ { key: 'options numberKey', value: 38 },
+ { key: 'options objectKey sub1', value: 12 },
+ { key: 'options objectKey sub2', value: 34 },
+ { key: 'options objectKey sub3', value: 56 },
+ { key: 'options stringKey', value: 'somethingElse' },
+ { key: 'otherOptions sub1 x', value: 42 },
+ { key: 'otherOptions sub2 y', value: 555 }
+ ]);
+ });
+
+ it('without parent key', () => {
+ component.appendParentKey = false;
+ component.ngOnInit();
+ expect(component.tableData).toEqual([
+ { key: 'keyWithEmptyObject', value: '' },
+ { key: 'numberKey', value: 38 },
+ { key: 'stringKey', value: 'somethingElse' },
+ { key: 'sub1', value: 12 },
+ { key: 'sub2', value: 34 },
+ { key: 'sub3', value: 56 },
+ { key: 'type', value: 'none' },
+ { key: 'x', value: 42 },
+ { key: 'y', value: 555 }
+ ]);
+ });
+ });
+
+ describe('subscribe fetchData', () => {
+ it('should not subscribe fetchData of table', () => {
+ component.ngOnInit();
+ expect(component.table.fetchData.observers.length).toBe(0);
+ });
+
+ it('should call fetchData', () => {
+ let called = false;
+ component.fetchData.subscribe(() => {
+ called = true;
+ });
+ component.ngOnInit();
+ expect(component.table.fetchData.observers.length).toBe(1);
+ component.table.fetchData.emit();
+ expect(called).toBeTruthy();
+ });
+ });
+
+ describe('hide empty items', () => {
+ beforeEach(() => {
+ component.data = {
+ booleanFalse: false,
+ booleanTrue: true,
+ string: '',
+ array: [],
+ object: {},
+ emptyObject: {
+ string: '',
+ array: [],
+ object: {}
+ },
+ someNumber: 0,
+ someDifferentNumber: 1,
+ someArray: [0, 1],
+ someString: '0',
+ someObject: {
+ empty: {},
+ something: 0.1
+ }
+ };
+ component.renderObjects = true;
+ });
+
+ it('should show all items as default', () => {
+ expect(component.hideEmpty).toBe(false);
+ component.ngOnInit();
+ expect(component.tableData).toEqual([
+ { key: 'array', value: '' },
+ { key: 'booleanFalse', value: false },
+ { key: 'booleanTrue', value: true },
+ { key: 'emptyObject array', value: '' },
+ { key: 'emptyObject object', value: '' },
+ { key: 'emptyObject string', value: '' },
+ { key: 'object', value: '' },
+ { key: 'someArray', value: '0, 1' },
+ { key: 'someDifferentNumber', value: 1 },
+ { key: 'someNumber', value: 0 },
+ { key: 'someObject empty', value: '' },
+ { key: 'someObject something', value: 0.1 },
+ { key: 'someString', value: '0' },
+ { key: 'string', value: '' }
+ ]);
+ });
+
+ it('should hide all empty items', () => {
+ component.hideEmpty = true;
+ component.ngOnInit();
+ expect(component.tableData).toEqual([
+ { key: 'booleanFalse', value: false },
+ { key: 'booleanTrue', value: true },
+ { key: 'someArray', value: '0, 1' },
+ { key: 'someDifferentNumber', value: 1 },
+ { key: 'someNumber', value: 0 },
+ { key: 'someObject something', value: 0.1 },
+ { key: 'someString', value: '0' }
+ ]);
+ });
+ });
+
+ describe('columns set up', () => {
+ let columns: CdTableColumn[];
+
+ beforeEach(() => {
+ columns = [
+ { prop: 'key', flexGrow: 1, cellTransformation: CellTemplate.bold },
+ { prop: 'value', flexGrow: 3 }
+ ];
+ });
+
+ it('should have the following default column set up', () => {
+ component.ngOnInit();
+ expect(component.columns).toEqual(columns);
+ });
+
+ it('should have the following column set up if customCss is defined', () => {
+ component.customCss = { 'class-name': 42 };
+ component.ngOnInit();
+ columns[1].cellTransformation = CellTemplate.classAdding;
+ expect(component.columns).toEqual(columns);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts
new file mode 100644
index 000000000..0f450ce2a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts
@@ -0,0 +1,224 @@
+import {
+ Component,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnInit,
+ Output,
+ ViewChild
+} from '@angular/core';
+
+import _ from 'lodash';
+
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { TableComponent } from '../table/table.component';
+
+interface KeyValueItem {
+ key: string;
+ value: any;
+}
+
+/**
+ * Display the given data in a 2 column data table. The left column
+ * shows the 'key' attribute, the right column the 'value' attribute.
+ * The data table has the following characteristics:
+ * - No header and footer is displayed
+ * - The relation of the width for the columns 'key' and 'value' is 1:3
+ * - The 'key' column is displayed in bold text
+ */
+@Component({
+ selector: 'cd-table-key-value',
+ templateUrl: './table-key-value.component.html',
+ styleUrls: ['./table-key-value.component.scss']
+})
+export class TableKeyValueComponent implements OnInit, OnChanges {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+
+ @Input()
+ data: any;
+ @Input()
+ autoReload: any = 5000;
+ @Input()
+ renderObjects = false;
+ // Only used if objects are rendered
+ @Input()
+ appendParentKey = true;
+ @Input()
+ hideEmpty = false;
+ @Input()
+ hideKeys: string[] = []; // Keys of pairs not to be displayed
+
+ // If set, the classAddingTpl is used to enable different css for different values
+ @Input()
+ customCss?: { [css: string]: number | string | ((any: any) => boolean) };
+
+ columns: Array<CdTableColumn> = [];
+ tableData: KeyValueItem[];
+
+ /**
+ * The function that will be called to update the input data.
+ */
+ @Output()
+ fetchData = new EventEmitter();
+
+ constructor(private datePipe: CdDatePipe) {}
+
+ ngOnInit() {
+ this.columns = [
+ {
+ prop: 'key',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.bold
+ },
+ {
+ prop: 'value',
+ flexGrow: 3
+ }
+ ];
+ if (this.customCss) {
+ this.columns[1].cellTransformation = CellTemplate.classAdding;
+ }
+ // We need to subscribe the 'fetchData' event here and not in the
+ // HTML template, otherwise the data table will display the loading
+ // indicator infinitely if data is only bound via '[data]="xyz"'.
+ // See for 'loadingIndicator' in 'TableComponent::ngOnInit()'.
+ if (this.fetchData.observers.length > 0) {
+ this.table.fetchData.subscribe(() => {
+ // Forward event triggered by the 'cd-table' data table.
+ this.fetchData.emit();
+ });
+ }
+ this.useData();
+ }
+
+ ngOnChanges() {
+ this.useData();
+ }
+
+ useData() {
+ if (!this.data) {
+ return; // Wait for data
+ }
+ let pairs = this.makePairs(this.data);
+ if (this.hideKeys) {
+ pairs = pairs.filter((pair) => !this.hideKeys.includes(pair.key));
+ }
+ this.tableData = pairs;
+ }
+
+ private makePairs(data: any): KeyValueItem[] {
+ let result: KeyValueItem[] = [];
+ if (!data) {
+ return undefined; // Wait for data
+ } else if (_.isArray(data)) {
+ result = this.makePairsFromArray(data);
+ } else if (_.isObject(data)) {
+ result = this.makePairsFromObject(data);
+ } else {
+ throw new Error('Wrong data format');
+ }
+ result = result
+ .map((item) => {
+ item.value = this.convertValue(item.value);
+ return item;
+ })
+ .filter((i) => i.value !== null);
+ return _.sortBy(this.renderObjects ? this.insertFlattenObjects(result) : result, 'key');
+ }
+
+ private makePairsFromArray(data: any[]): KeyValueItem[] {
+ let temp: any[] = [];
+ const first = data[0];
+ if (_.isArray(first)) {
+ if (first.length === 2) {
+ temp = data.map((a) => ({
+ key: a[0],
+ value: a[1]
+ }));
+ } else {
+ throw new Error(
+ `Array contains too many elements (${first.length}). ` +
+ `Needs to be of type [string, any][]`
+ );
+ }
+ } else if (_.isObject(first)) {
+ if (_.has(first, 'key') && _.has(first, 'value')) {
+ temp = [...data];
+ } else {
+ temp = data.reduce(
+ (previous: any[], item) => previous.concat(this.makePairsFromObject(item)),
+ temp
+ );
+ }
+ }
+ return temp;
+ }
+
+ private makePairsFromObject(data: any): KeyValueItem[] {
+ return Object.keys(data).map((k) => ({
+ key: k,
+ value: data[k]
+ }));
+ }
+
+ private insertFlattenObjects(data: KeyValueItem[]): any[] {
+ return _.flattenDeep(
+ data.map((item) => {
+ const value = item.value;
+ const isObject = _.isObject(value);
+ if (!isObject || _.isEmpty(value)) {
+ if (isObject) {
+ item.value = '';
+ }
+ return item;
+ }
+ return this.splitItemIntoItems(item);
+ })
+ );
+ }
+
+ /**
+ * Split item into items will call _makePairs inside _makePairs (recursion), in oder to split
+ * the object item up into items as planned.
+ */
+ private splitItemIntoItems(data: { key: string; value: object }): KeyValueItem[] {
+ return this.makePairs(data.value).map((item) => {
+ if (this.appendParentKey) {
+ item.key = data.key + ' ' + item.key;
+ }
+ return item;
+ });
+ }
+
+ private convertValue(value: any): KeyValueItem {
+ if (_.isArray(value)) {
+ if (_.isEmpty(value) && this.hideEmpty) {
+ return null;
+ }
+ value = value.map((item) => (_.isObject(item) ? JSON.stringify(item) : item)).join(', ');
+ } else if (_.isObject(value)) {
+ if ((this.hideEmpty && _.isEmpty(value)) || !this.renderObjects) {
+ return null;
+ }
+ } else if (_.isString(value)) {
+ if (value === '' && this.hideEmpty) {
+ return null;
+ }
+ if (this.isDate(value)) {
+ value = this.datePipe.transform(value) || value;
+ }
+ }
+
+ return value;
+ }
+
+ private isDate(s: string) {
+ const sep = '[ -:.TZ]';
+ const n = '\\d{2}' + sep;
+ // year - m - d - h : m : s . someRest Z (if UTC)
+ return s.match(new RegExp('^\\d{4}' + sep + n + n + n + n + n + '\\d*' + 'Z?$'));
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
new file mode 100644
index 000000000..6212c95c8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
@@ -0,0 +1,327 @@
+<div class="dataTables_wrapper">
+
+ <div *ngIf="onlyActionHeader"
+ class="dataTables_header clearfix">
+ <div class="cd-datatable-actions">
+ <ng-content select=".only-table-actions"></ng-content>
+ </div>
+ </div>
+ <div class="dataTables_header clearfix"
+ *ngIf="toolHeader">
+ <!-- actions -->
+ <div class="cd-datatable-actions">
+ <ng-content select=".table-actions"></ng-content>
+ </div>
+ <!-- end actions -->
+
+ <!-- column filters -->
+ <div *ngIf="columnFilters.length !== 0"
+ class="btn-group widget-toolbar">
+ <div ngbDropdown
+ placement="bottom-right"
+ class="tc_filter_name">
+ <button ngbDropdownToggle
+ class="btn btn-light">
+ <i [ngClass]="[icons.large, icons.filter]"></i>
+ {{ selectedFilter.column.name }}
+ </button>
+ <div ngbDropdownMenu>
+ <ng-container *ngFor="let filter of columnFilters">
+ <button ngbDropdownItem
+ (click)="onSelectFilter(filter); false">{{ filter.column.name }}</button>
+ </ng-container>
+ </div>
+ </div>
+
+ <div ngbDropdown
+ placement="bottom-right"
+ class="tc_filter_option">
+ <button ngbDropdownToggle
+ class="btn btn-light"
+ [class.disabled]="selectedFilter.options.length === 0">
+ {{ selectedFilter.value ? selectedFilter.value.formatted: 'Any' }}
+ </button>
+ <div ngbDropdownMenu>
+ <ng-container *ngFor="let option of selectedFilter.options">
+ <button ngbDropdownItem
+ (click)="onChangeFilter(selectedFilter, option); false">
+ {{ option.formatted }}
+ <i *ngIf="selectedFilter.value !== undefined && (selectedFilter.value.raw === option.raw)"
+ [ngClass]="[icons.check]"></i>
+ </button>
+ </ng-container>
+ </div>
+ </div>
+ </div>
+ <!-- end column filters -->
+
+ <!-- search -->
+ <div class="input-group search"
+ *ngIf="searchField">
+ <span class="input-group-prepend">
+ <span class="input-group-text">
+ <i [ngClass]="[icons.search]"></i>
+ </span>
+ </span>
+ <input class="form-control"
+ type="text"
+ [(ngModel)]="search"
+ (keyup)="updateFilter()">
+ <div class="input-group-append">
+ <button type="button"
+ class="btn btn-light"
+ (click)="onClearSearch()">
+ <i class="icon-prepend {{ icons.destroy }}"></i>
+ </button>
+ </div>
+ </div>
+ <!-- end search -->
+
+ <!-- pagination limit -->
+ <div class="input-group dataTables_paginate"
+ *ngIf="limit">
+ <input class="form-control"
+ type="number"
+ min="1"
+ max="9999"
+ [value]="userConfig.limit"
+ (click)="setLimit($event)"
+ (keyup)="setLimit($event)"
+ (blur)="setLimit($event)">
+ </div>
+ <!-- end pagination limit-->
+
+ <!-- show hide columns -->
+ <div class="widget-toolbar">
+ <div ngbDropdown
+ autoClose="outside"
+ class="tc_menuitem">
+ <button ngbDropdownToggle
+ class="btn btn-light tc_columnBtn">
+ <i [ngClass]="[icons.large, icons.table]"></i>
+ </button>
+ <div ngbDropdownMenu>
+ <ng-container *ngFor="let column of columns">
+ <button ngbDropdownItem
+ *ngIf="column.name !== ''"
+ (click)="toggleColumn(column); false;">
+ <div class="custom-control custom-checkbox py-0">
+ <input class="custom-control-input"
+ type="checkbox"
+ [name]="column.prop"
+ [id]="column.prop"
+ [checked]="!column.isHidden">
+ <label class="custom-control-label"
+ [for]="column.prop">{{ column.name }}</label>
+ </div>
+ </button>
+ </ng-container>
+ </div>
+ </div>
+ </div>
+ <!-- end show hide columns -->
+
+ <!-- refresh button -->
+ <div class="widget-toolbar tc_refreshBtn"
+ *ngIf="fetchData.observers.length > 0">
+
+ <button type="button"
+ [class]="'btn btn-' + status.type"
+ [ngbTooltip]="status.msg"
+ (click)="refreshBtn()">
+ <i [ngClass]="[icons.large, icons.refresh]"
+ [class.fa-spin]="updating || loadingIndicator"></i>
+ </button>
+ </div>
+ <!-- end refresh button -->
+ </div>
+ <div class="dataTables_header clearfix"
+ *ngIf="toolHeader && columnFiltered">
+ <!-- filter chips for column filters -->
+ <div class="filter-chips">
+ <span *ngFor="let filter of columnFilters">
+ <span *ngIf="filter.value"
+ class="badge badge-info mr-2">
+ <span class="mr-2">{{ filter.column.name }}: {{ filter.value.formatted }}</span>
+ <a class="badge-remove"
+ (click)="onChangeFilter(filter); false">
+ <i [ngClass]="[icons.destroy]"
+ aria-hidden="true"></i>
+ </a>
+ </span>
+ </span>
+ <a class="tc_clearSelections"
+ href=""
+ (click)="onClearFilters(); false">
+ <ng-container i18n>Clear filters</ng-container>
+ </a>
+ </div>
+ <!-- end filter chips for column filters -->
+ </div>
+ <ngx-datatable #table
+ class="bootstrap cd-datatable"
+ [cssClasses]="paginationClasses"
+ [selectionType]="selectionType"
+ [selected]="selection.selected"
+ (select)="onSelect($event)"
+ [sorts]="userConfig.sorts"
+ (sort)="changeSorting($event)"
+ [columns]="tableColumns"
+ [columnMode]="columnMode"
+ [rows]="rows"
+ [rowClass]="getRowClass()"
+ [headerHeight]="header ? 'auto' : 0"
+ [footerHeight]="footer ? 'auto' : 0"
+ [count]="count"
+ [externalPaging]="serverSide"
+ [externalSorting]="serverSide"
+ [limit]="userConfig.limit > 0 ? userConfig.limit : undefined"
+ [offset]="userConfig.offset >= 0 ? userConfig.offset : 0"
+ (page)="changePage($event)"
+ [loadingIndicator]="loadingIndicator"
+ [rowIdentity]="rowIdentity()"
+ [rowHeight]="'auto'">
+
+ <!-- Row Detail Template -->
+ <ngx-datatable-row-detail rowHeight="auto"
+ #detailRow>
+ <ng-template let-row="row"
+ let-expanded="expanded"
+ ngx-datatable-row-detail-template>
+ <!-- Table Details -->
+ <ng-content select="[cdTableDetail]"></ng-content>
+ </ng-template>
+ </ngx-datatable-row-detail>
+
+ <ngx-datatable-footer>
+ <ng-template ngx-datatable-footer-template
+ let-rowCount="rowCount"
+ let-pageSize="pageSize"
+ let-selectedCount="selectedCount"
+ let-curPage="curPage"
+ let-offset="offset"
+ let-isVisible="isVisible">
+ <div class="page-count">
+ <span *ngIf="selectionType">
+ {{ selectedCount }} <ng-container i18n="X selected">selected</ng-container> /
+ </span>
+
+ <!-- rowCount might have different semantics with or without serverSide.
+ We treat serverSide (backend-driven tables) as a specific case.
+ -->
+ <span *ngIf="!serverSide else serverSideTpl">
+ <span *ngIf="rowCount != data?.length">
+ {{ rowCount }} <ng-container i18n="X found">found</ng-container> /
+ </span>
+ {{ data?.length || 0 }} <ng-container i18n="X total">total</ng-container>
+ </span>
+
+ <ng-template #serverSideTpl>
+ {{ data?.length || 0 }} <ng-container i18n="X found">found</ng-container> /
+ {{ rowCount }} <ng-container i18n="X total">total</ng-container>
+ </ng-template>
+ </div>
+ <datatable-pager [pagerLeftArrowIcon]="paginationClasses.pagerPrevious"
+ [pagerRightArrowIcon]="paginationClasses.pagerNext"
+ [pagerPreviousIcon]="paginationClasses.pagerLeftArrow"
+ [pagerNextIcon]="paginationClasses.pagerRightArrow"
+ [page]="curPage"
+ [size]="pageSize"
+ [count]="rowCount"
+ [hidden]="!((rowCount / pageSize) > 1)"
+ (change)="table.onFooterPage($event)">
+ </datatable-pager>
+ </ng-template>
+ </ngx-datatable-footer>
+ </ngx-datatable>
+</div>
+
+<!-- cell templates that can be accessed from outside -->
+<ng-template #tableCellBoldTpl
+ let-value="value">
+ <strong>{{ value }}</strong>
+</ng-template>
+
+<ng-template #sparklineTpl
+ let-row="row"
+ let-value="value">
+ <cd-sparkline [data]="value"
+ [isBinary]="row.cdIsBinary"></cd-sparkline>
+</ng-template>
+
+<ng-template #routerLinkTpl
+ let-row="row"
+ let-value="value">
+ <a [routerLink]="[row.cdLink]"
+ [queryParams]="row.cdParams">{{ value }}</a>
+</ng-template>
+
+<ng-template #checkIconTpl
+ let-value="value">
+ <i [ngClass]="[icons.check]"
+ [hidden]="!(value | boolean)"></i>
+</ng-template>
+
+<ng-template #perSecondTpl
+ let-row="row"
+ let-value="value">
+ {{ value | dimless }} /s
+</ng-template>
+
+<ng-template #executingTpl
+ let-column="column"
+ let-row="row"
+ let-value="value">
+ <i [ngClass]="[icons.spinner, icons.spin]"
+ *ngIf="row.cdExecuting"></i>
+ <span [ngClass]="column?.customTemplateConfig?.valueClass">
+ {{ value }}
+ </span>
+ <span *ngIf="row.cdExecuting"
+ [ngClass]="column?.customTemplateConfig?.executingClass ? column.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span>
+</ng-template>
+
+<ng-template #classAddingTpl
+ let-value="value">
+ <span class="{{ value | pipeFunction:useCustomClass:this }}">{{ value }}</span>
+</ng-template>
+
+<ng-template #badgeTpl
+ let-column="column"
+ let-value="value">
+ <span *ngFor="let item of (value | array); last as last">
+ <span class="badge"
+ [ngClass]="(column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.class) ? column.customTemplateConfig.map[item].class : (column?.customTemplateConfig?.class ? column.customTemplateConfig.class : 'badge-primary')"
+ *ngIf="(column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.value) ? column.customTemplateConfig.map[item].value : column?.customTemplateConfig?.prefix ? column.customTemplateConfig.prefix + item : item">
+ {{ (column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.value) ? column.customTemplateConfig.map[item].value : column?.customTemplateConfig?.prefix ? column.customTemplateConfig.prefix + item : item }}
+ </span>
+ <span *ngIf="!last">&nbsp;</span>
+ </span>
+</ng-template>
+
+<ng-template #mapTpl
+ let-column="column"
+ let-value="value">
+ <span>{{ value | map:column?.customTemplateConfig }}</span>
+</ng-template>
+
+<ng-template #truncateTpl
+ let-column="column"
+ let-value="value">
+ <span data-toggle="tooltip"
+ [title]="value">{{ value | truncate:column?.customTemplateConfig?.length:column?.customTemplateConfig?.omission }}</span>
+</ng-template>
+
+<ng-template #rowDetailsTpl
+ let-row="row"
+ let-isExpanded="expanded"
+ ngx-datatable-cell-template>
+ <a href="javascript:void(0)"
+ [class.expand-collapse-icon-right]="!isExpanded"
+ [class.expand-collapse-icon-down]="isExpanded"
+ class="expand-collapse-icon tc_expand-collapse"
+ title="Expand/Collapse Row"
+ i18n-title
+ (click)="toggleExpandRow(row, isExpanded, $event)">
+ </a>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss
new file mode 100644
index 000000000..57b8e48de
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss
@@ -0,0 +1,295 @@
+@use './src/styles/vendor/variables' as vv;
+@use './src/styles/defaults/mixins';
+
+@mixin row-details-icon {
+ color: vv.$gray-900;
+ font-family: 'ForkAwesome', sans-serif;
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.dataTables_wrapper {
+ margin-bottom: 25px;
+ // after bootstrap 8.0 the details table started to
+ // have an issue where the columns keep expanding to
+ // infinity.
+ // https://github.com/ceph/ceph/pull/40618#pullrequestreview-629010639
+ // making the max-width to 99.9% solves the issue as a temporary fix
+ // until we get a conclusive fix, this needs to be kept.
+ max-width: 99.9%;
+
+ .separator {
+ border-left: 1px solid vv.$datatable-divider-color;
+ display: inline-block;
+ height: 30px;
+ margin-left: 5px;
+ padding-left: 5px;
+ vertical-align: middle;
+ }
+
+ .widget-toolbar {
+ border-left: 1px solid vv.$datatable-divider-color;
+ float: right;
+ padding: 0 8px;
+
+ .form-check {
+ padding-left: 0;
+ }
+ }
+
+ .dataTables_length > input {
+ line-height: 25px;
+ text-align: right;
+ }
+}
+
+.dataTables_header {
+ background-color: vv.$gray-100;
+ border: 1px solid vv.$gray-400;
+ border-bottom: 0;
+ padding: 5px;
+ position: relative;
+
+ .cd-datatable-actions {
+ float: left;
+ }
+
+ .form-group {
+ padding-left: 8px;
+ }
+
+ .input-group {
+ border-left: 1px solid vv.$datatable-divider-color;
+ float: right;
+ max-width: 250px;
+ padding-left: 8px;
+ padding-right: 8px;
+ width: 40%;
+
+ .form-control {
+ height: 30px;
+ }
+ }
+
+ .input-group.dataTables_paginate {
+ min-width: 85px;
+ padding-right: 8px;
+ width: 8%;
+ }
+
+ .filter-chips {
+ float: right;
+ padding: 0 8px;
+
+ .badge-remove {
+ color: vv.$white;
+ }
+ }
+}
+
+::ng-deep cd-table .cd-datatable {
+ border: 1px solid vv.$gray-400;
+ margin-bottom: 0;
+ max-width: none !important;
+
+ .progress-linear {
+ display: block;
+ height: 5px;
+ margin: 0;
+ padding: 0;
+ position: relative;
+ width: 100%;
+
+ .container {
+ background-color: vv.$primary;
+
+ .bar {
+ background-color: vv.$primary;
+ height: 100%;
+ left: 0;
+ overflow: hidden;
+ position: absolute;
+ width: 100%;
+ }
+
+ .bar::before {
+ animation: progress-loading 3s linear infinite;
+ background-color: vv.$primary;
+ content: '';
+ display: block;
+ height: 100%;
+ left: -200px;
+ position: absolute;
+ width: 200px;
+ }
+ }
+ }
+
+ .datatable-header {
+ background-clip: padding-box;
+ background-color: vv.$gray-100;
+ background-image: linear-gradient(to bottom, vv.$gray-100 0, vv.$gray-200 100%);
+ background-repeat: repeat-x;
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0);
+
+ .sort-asc,
+ .sort-desc {
+ color: vv.$primary;
+ }
+
+ .datatable-header-cell {
+ @include mixins.table-cell;
+
+ font-weight: bold;
+ text-align: left;
+
+ .datatable-header-cell-label {
+ &::after {
+ font-family: ForkAwesome;
+ font-weight: 400;
+ height: 9px;
+ left: 10px;
+ line-height: 12px;
+ position: relative;
+ vertical-align: baseline;
+ width: 12px;
+ }
+ }
+
+ &.sortable {
+ .datatable-header-cell-label::after {
+ content: ' \f0dc';
+ }
+
+ &.sort-active {
+ &.sort-asc .datatable-header-cell-label::after {
+ content: ' \f160';
+ }
+
+ &.sort-desc .datatable-header-cell-label::after {
+ content: ' \f161';
+ }
+ }
+ }
+
+ &:first-child {
+ border-left: 0;
+ }
+ }
+ }
+
+ .datatable-body {
+ margin-bottom: -6px;
+
+ .empty-row {
+ background-color: lighten(vv.$primary, 45%);
+ font-style: italic;
+ font-weight: bold;
+ padding-bottom: 5px;
+ padding-top: 5px;
+ text-align: center;
+ }
+
+ .datatable-body-row {
+ &.clickable:hover .datatable-row-group {
+ background-color: lighten(vv.$primary, 45%);
+ transition-duration: 0.3s;
+ transition-property: background;
+ transition-timing-function: linear;
+ }
+
+ &.datatable-row-even {
+ background-color: vv.$white;
+ }
+
+ &.datatable-row-odd {
+ background-color: vv.$gray-100;
+ }
+
+ &.active,
+ &.active:hover {
+ background-color: lighten(vv.$primary, 35%);
+ }
+
+ .datatable-body-cell {
+ @include mixins.table-cell;
+
+ &:first-child {
+ border-left: 0;
+ }
+
+ .datatable-body-cell-label {
+ display: block;
+ height: 100%;
+ }
+ }
+ }
+
+ .datatable-row-detail {
+ border-bottom: 2px solid vv.$gray-400;
+ overflow-y: visible !important;
+ padding: 20px;
+ }
+
+ .expand-collapse-icon {
+ display: block;
+ height: 100%;
+ text-align: center;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ .expand-collapse-icon-right::before {
+ @include row-details-icon;
+ content: '\f105';
+ }
+
+ .expand-collapse-icon-down::before {
+ @include row-details-icon;
+ content: '\f107';
+ }
+ }
+
+ .datatable-footer {
+ .selected-count,
+ .page-count {
+ font-style: italic;
+ min-height: 2rem;
+ padding-left: 0.3rem;
+ padding-top: 0.3rem;
+ }
+ }
+
+ .cd-datatable-checkbox {
+ text-align: center;
+ }
+}
+
+@keyframes progress-loading {
+ from {
+ left: -200px;
+ width: 15%;
+ }
+
+ 50% {
+ width: 30%;
+ }
+
+ 70% {
+ width: 70%;
+ }
+
+ 80% {
+ left: 50%;
+ }
+
+ 95% {
+ left: 120%;
+ }
+
+ to {
+ left: 100%;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts
new file mode 100644
index 000000000..f0f649780
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts
@@ -0,0 +1,799 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import _ from 'lodash';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TableComponent } from './table.component';
+
+describe('TableComponent', () => {
+ let component: TableComponent;
+ let fixture: ComponentFixture<TableComponent>;
+
+ const createFakeData = (n: number) => {
+ const data = [];
+ for (let i = 0; i < n; i++) {
+ data.push({
+ a: i,
+ b: i * 10,
+ c: !!(i % 2)
+ });
+ }
+ return data;
+ };
+
+ const clearLocalStorage = () => {
+ component.localStorage.clear();
+ };
+
+ configureTestBed({
+ declarations: [TableComponent],
+ imports: [
+ BrowserAnimationsModule,
+ NgxDatatableModule,
+ NgxPipeFunctionModule,
+ FormsModule,
+ ComponentsModule,
+ RouterTestingModule,
+ NgbDropdownModule,
+ PipesModule,
+ NgbTooltipModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TableComponent);
+ component = fixture.componentInstance;
+
+ component.data = createFakeData(10);
+ component.localColumns = component.columns = [
+ { prop: 'a', name: 'Index', filterable: true },
+ { prop: 'b', name: 'Index times ten' },
+ { prop: 'c', name: 'Odd?', filterable: true }
+ ];
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should force an identifier', () => {
+ component.identifier = 'x';
+ component.forceIdentifier = true;
+ component.ngOnInit();
+ expect(component.identifier).toBe('x');
+ expect(component.sorts[0].prop).toBe('a');
+ expect(component.sorts).toEqual(component.createSortingDefinition('a'));
+ });
+
+ it('should have rows', () => {
+ component.useData();
+ expect(component.data.length).toBe(10);
+ expect(component.rows.length).toBe(component.data.length);
+ });
+
+ it('should have an int in setLimit parsing a string', () => {
+ expect(component.limit).toBe(10);
+ expect(component.limit).toEqual(jasmine.any(Number));
+
+ const e = { target: { value: '1' } };
+ component.setLimit(e);
+ expect(component.userConfig.limit).toBe(1);
+ expect(component.userConfig.limit).toEqual(jasmine.any(Number));
+ e.target.value = '-20';
+ component.setLimit(e);
+ expect(component.userConfig.limit).toBe(1);
+ });
+
+ it('should prevent propagation of mouseenter event', (done) => {
+ let wasCalled = false;
+ const mouseEvent = new MouseEvent('mouseenter');
+ mouseEvent.stopPropagation = () => {
+ wasCalled = true;
+ };
+ spyOn(component.table.element, 'addEventListener').and.callFake((eventName, fn) => {
+ fn(mouseEvent);
+ expect(eventName).toBe('mouseenter');
+ expect(wasCalled).toBe(true);
+ done();
+ });
+ component.ngOnInit();
+ });
+
+ it('should call updateSelection on init', () => {
+ component.updateSelection.subscribe((selection: CdTableSelection) => {
+ expect(selection.hasSelection).toBeFalsy();
+ expect(selection.hasSingleSelection).toBeFalsy();
+ expect(selection.hasMultiSelection).toBeFalsy();
+ expect(selection.selected.length).toBe(0);
+ });
+ component.ngOnInit();
+ });
+
+ describe('test column filtering', () => {
+ let filterIndex: CdTableColumnFilter;
+ let filterOdd: CdTableColumnFilter;
+ let filterCustom: CdTableColumnFilter;
+
+ const expectColumnFilterCreated = (
+ filter: CdTableColumnFilter,
+ prop: string,
+ options: string[],
+ value?: { raw: string; formatted: string }
+ ) => {
+ expect(filter.column.prop).toBe(prop);
+ expect(_.map(filter.options, 'raw')).toEqual(options);
+ expect(filter.value).toEqual(value);
+ };
+
+ const expectColumnFiltered = (
+ changes: { filter: CdTableColumnFilter; value?: string }[],
+ results: any[],
+ search: string = ''
+ ) => {
+ component.search = search;
+ _.forEach(changes, (change) => {
+ component.onChangeFilter(
+ change.filter,
+ change.value ? { raw: change.value, formatted: change.value } : undefined
+ );
+ });
+ expect(component.rows).toEqual(results);
+ component.onClearSearch();
+ component.onClearFilters();
+ };
+
+ describe('with visible columns', () => {
+ beforeEach(() => {
+ component.initColumnFilters();
+ component.updateColumnFilterOptions();
+ filterIndex = component.columnFilters[0];
+ filterOdd = component.columnFilters[1];
+ });
+
+ it('should have filters initialized', () => {
+ expect(component.columnFilters.length).toBe(2);
+ expectColumnFilterCreated(
+ filterIndex,
+ 'a',
+ _.map(component.data, (row) => _.toString(row.a))
+ );
+ expectColumnFilterCreated(filterOdd, 'c', ['false', 'true']);
+ });
+
+ it('should add filters', () => {
+ // single
+ expectColumnFiltered([{ filter: filterIndex, value: '1' }], [{ a: 1, b: 10, c: true }]);
+
+ // multiple
+ expectColumnFiltered(
+ [
+ { filter: filterOdd, value: 'false' },
+ { filter: filterIndex, value: '2' }
+ ],
+ [{ a: 2, b: 20, c: false }]
+ );
+
+ // Clear should work
+ expect(component.rows).toEqual(component.data);
+ });
+
+ it('should remove filters', () => {
+ // single
+ expectColumnFiltered(
+ [
+ { filter: filterOdd, value: 'true' },
+ { filter: filterIndex, value: '1' },
+ { filter: filterIndex, value: undefined }
+ ],
+ [
+ { a: 1, b: 10, c: true },
+ { a: 3, b: 30, c: true },
+ { a: 5, b: 50, c: true },
+ { a: 7, b: 70, c: true },
+ { a: 9, b: 90, c: true }
+ ]
+ );
+
+ // multiple
+ expectColumnFiltered(
+ [
+ { filter: filterOdd, value: 'true' },
+ { filter: filterIndex, value: '1' },
+ { filter: filterIndex, value: undefined },
+ { filter: filterOdd, value: undefined }
+ ],
+ component.data
+ );
+
+ // a selected filter should be removed if it's selected again
+ expectColumnFiltered(
+ [
+ { filter: filterOdd, value: 'true' },
+ { filter: filterIndex, value: '1' },
+ { filter: filterIndex, value: '1' }
+ ],
+ [
+ { a: 1, b: 10, c: true },
+ { a: 3, b: 30, c: true },
+ { a: 5, b: 50, c: true },
+ { a: 7, b: 70, c: true },
+ { a: 9, b: 90, c: true }
+ ]
+ );
+ });
+
+ it('should search from filtered rows', () => {
+ expectColumnFiltered(
+ [{ filter: filterOdd, value: 'true' }],
+ [{ a: 9, b: 90, c: true }],
+ '9'
+ );
+
+ // Clear should work
+ expect(component.rows).toEqual(component.data);
+ });
+ });
+
+ describe('with custom columns', () => {
+ beforeEach(() => {
+ // create a new additional column in data
+ for (let i = 0; i < component.data.length; i++) {
+ const row = component.data[i];
+ row['d'] = row.a;
+ }
+ // create a custom column filter
+ component.extraFilterableColumns = [
+ {
+ name: 'd less than 5',
+ prop: 'd',
+ filterOptions: ['yes', 'no'],
+ filterInitValue: 'yes',
+ filterPredicate: (row, value) => {
+ if (value === 'yes') {
+ return row.d < 5;
+ } else {
+ return row.d >= 5;
+ }
+ }
+ }
+ ];
+ component.initColumnFilters();
+ component.updateColumnFilterOptions();
+ filterIndex = component.columnFilters[0];
+ filterOdd = component.columnFilters[1];
+ filterCustom = component.columnFilters[2];
+ });
+
+ it('should have filters initialized', () => {
+ expect(component.columnFilters.length).toBe(3);
+ expectColumnFilterCreated(filterCustom, 'd', ['yes', 'no'], {
+ raw: 'yes',
+ formatted: 'yes'
+ });
+ component.useData();
+ expect(component.rows).toEqual(_.slice(component.data, 0, 5));
+ });
+
+ it('should remove filters', () => {
+ expectColumnFiltered([{ filter: filterCustom, value: 'no' }], _.slice(component.data, 5));
+ });
+ });
+ });
+
+ describe('test search', () => {
+ const expectSearch = (keyword: string, expectedResult: object[]) => {
+ component.search = keyword;
+ component.updateFilter();
+ expect(component.rows).toEqual(expectedResult);
+ component.onClearSearch();
+ };
+
+ describe('searchableObjects', () => {
+ const testObject = {
+ obj: {
+ min: 8,
+ max: 123
+ }
+ };
+
+ beforeEach(() => {
+ component.data = [testObject];
+ component.localColumns = [{ prop: 'obj', name: 'Object' }];
+ });
+
+ it('should not search through objects as default case', () => {
+ expect(component.searchableObjects).toBe(false);
+ expectSearch('8', []);
+ });
+
+ it('should search through objects if searchableObjects is set to true', () => {
+ component.searchableObjects = true;
+ expectSearch('28', []);
+ expectSearch('8', [testObject]);
+ expectSearch('123', [testObject]);
+ expectSearch('max', [testObject]);
+ });
+ });
+
+ it('should find a particular number', () => {
+ expectSearch('5', [{ a: 5, b: 50, c: true }]);
+ expectSearch('9', [{ a: 9, b: 90, c: true }]);
+ });
+
+ it('should find boolean values', () => {
+ expectSearch('true', [
+ { a: 1, b: 10, c: true },
+ { a: 3, b: 30, c: true },
+ { a: 5, b: 50, c: true },
+ { a: 7, b: 70, c: true },
+ { a: 9, b: 90, c: true }
+ ]);
+ expectSearch('false', [
+ { a: 0, b: 0, c: false },
+ { a: 2, b: 20, c: false },
+ { a: 4, b: 40, c: false },
+ { a: 6, b: 60, c: false },
+ { a: 8, b: 80, c: false }
+ ]);
+ });
+
+ it('should test search keyword preparation', () => {
+ const prepare = TableComponent.prepareSearch;
+ const expected = ['a', 'b', 'c'];
+ expect(prepare('a b c')).toEqual(expected);
+ expect(prepare('a,, b,, c')).toEqual(expected);
+ expect(prepare('a,,,, b,,, c')).toEqual(expected);
+ expect(prepare('a+b c')).toEqual(['a+b', 'c']);
+ expect(prepare('a,,,+++b,,, c')).toEqual(['a+++b', 'c']);
+ expect(prepare('"a b c" "d e f", "g, h i"')).toEqual(['a+b+c', 'd+e++f', 'g+h+i']);
+ });
+
+ it('should search for multiple values', () => {
+ expectSearch('2 20 false', [{ a: 2, b: 20, c: false }]);
+ expectSearch('false 2', [{ a: 2, b: 20, c: false }]);
+ });
+
+ it('should filter by column', () => {
+ expectSearch('index:5', [{ a: 5, b: 50, c: true }]);
+ expectSearch('times:50', [{ a: 5, b: 50, c: true }]);
+ expectSearch('times:50 index:5', [{ a: 5, b: 50, c: true }]);
+ expectSearch('Odd?:true', [
+ { a: 1, b: 10, c: true },
+ { a: 3, b: 30, c: true },
+ { a: 5, b: 50, c: true },
+ { a: 7, b: 70, c: true },
+ { a: 9, b: 90, c: true }
+ ]);
+ component.data = createFakeData(100);
+ expectSearch('index:1 odd:true times:110', [{ a: 11, b: 110, c: true }]);
+ });
+
+ it('should search through arrays', () => {
+ component.localColumns = [
+ { prop: 'a', name: 'Index' },
+ { prop: 'b', name: 'ArrayColumn' }
+ ];
+
+ component.data = [
+ { a: 1, b: ['foo', 'bar'] },
+ { a: 2, b: ['baz', 'bazinga'] }
+ ];
+ expectSearch('bar', [{ a: 1, b: ['foo', 'bar'] }]);
+ expectSearch('arraycolumn:bar arraycolumn:foo', [{ a: 1, b: ['foo', 'bar'] }]);
+ expectSearch('arraycolumn:baz arraycolumn:inga', [{ a: 2, b: ['baz', 'bazinga'] }]);
+
+ component.data = [
+ { a: 1, b: [1, 2] },
+ { a: 2, b: [3, 4] }
+ ];
+ expectSearch('arraycolumn:1 arraycolumn:2', [{ a: 1, b: [1, 2] }]);
+ });
+
+ it('should search with spaces', () => {
+ const expectedResult = [{ a: 2, b: 20, c: false }];
+ expectSearch(`'Index times ten':20`, expectedResult);
+ expectSearch('index+times+ten:20', expectedResult);
+ });
+
+ it('should filter results although column name is incomplete', () => {
+ component.data = createFakeData(3);
+ expectSearch(`'Index times ten'`, []);
+ expectSearch(`'Ind'`, []);
+ expectSearch(`'Ind:'`, [
+ { a: 0, b: 0, c: false },
+ { a: 1, b: 10, c: true },
+ { a: 2, b: 20, c: false }
+ ]);
+ });
+
+ it('should search if column name is incomplete', () => {
+ const expectedData = [
+ { a: 0, b: 0, c: false },
+ { a: 1, b: 10, c: true },
+ { a: 2, b: 20, c: false }
+ ];
+ component.data = _.clone(expectedData);
+ expectSearch('inde', []);
+ expectSearch('index:', expectedData);
+ expectSearch('index times te', []);
+ });
+
+ it('should restore full table after search', () => {
+ component.useData();
+ expect(component.rows.length).toBe(10);
+ component.search = '3';
+ component.updateFilter();
+ expect(component.rows.length).toBe(1);
+ component.onClearSearch();
+ expect(component.rows.length).toBe(10);
+ });
+
+ it('should work with undefined data', () => {
+ component.data = undefined;
+ component.search = '3';
+ component.updateFilter();
+ expect(component.rows).toBeUndefined();
+ });
+ });
+
+ describe('after ngInit', () => {
+ const toggleColumn = (prop: string, checked: boolean) => {
+ component.toggleColumn({
+ prop: prop,
+ isHidden: checked
+ });
+ };
+
+ const equalStorageConfig = () => {
+ expect(JSON.stringify(component.userConfig)).toBe(
+ component.localStorage.getItem(component.tableName)
+ );
+ };
+
+ beforeEach(() => {
+ component.ngOnInit();
+ });
+
+ it('should have updated the column definitions', () => {
+ expect(component.localColumns[0].flexGrow).toBe(1);
+ expect(component.localColumns[1].flexGrow).toBe(2);
+ expect(component.localColumns[2].flexGrow).toBe(2);
+ expect(component.localColumns[2].resizeable).toBe(false);
+ });
+
+ it('should have table columns', () => {
+ expect(component.tableColumns.length).toBe(3);
+ expect(component.tableColumns).toEqual(component.localColumns);
+ });
+
+ it('should have a unique identifier which it searches for', () => {
+ expect(component.identifier).toBe('a');
+ expect(component.userConfig.sorts[0].prop).toBe('a');
+ expect(component.userConfig.sorts).toEqual(component.createSortingDefinition('a'));
+ equalStorageConfig();
+ });
+
+ it('should remove column "a"', () => {
+ expect(component.userConfig.sorts[0].prop).toBe('a');
+ toggleColumn('a', false);
+ expect(component.userConfig.sorts[0].prop).toBe('b');
+ expect(component.tableColumns.length).toBe(2);
+ equalStorageConfig();
+ });
+
+ it('should not be able to remove all columns', () => {
+ expect(component.userConfig.sorts[0].prop).toBe('a');
+ toggleColumn('a', false);
+ toggleColumn('b', false);
+ toggleColumn('c', false);
+ expect(component.userConfig.sorts[0].prop).toBe('c');
+ expect(component.tableColumns.length).toBe(1);
+ equalStorageConfig();
+ });
+
+ it('should enable column "a" again', () => {
+ expect(component.userConfig.sorts[0].prop).toBe('a');
+ toggleColumn('a', false);
+ toggleColumn('a', true);
+ expect(component.userConfig.sorts[0].prop).toBe('b');
+ expect(component.tableColumns.length).toBe(3);
+ equalStorageConfig();
+ });
+
+ it('should toggle on off columns', () => {
+ for (const column of component.columns) {
+ component.toggleColumn(column);
+ expect(column.isHidden).toBeTruthy();
+ component.toggleColumn(column);
+ expect(column.isHidden).toBeFalsy();
+ }
+ });
+
+ afterEach(() => {
+ clearLocalStorage();
+ });
+ });
+
+ describe('test cell transformations', () => {
+ interface ExecutingTemplateConfig {
+ valueClass?: string;
+ executingClass?: string;
+ }
+
+ const testExecutingTemplate = (templateConfig?: ExecutingTemplateConfig) => {
+ const state = 'updating';
+ const value = component.data[0].a;
+
+ component.autoReload = -1;
+ component.columns[0].cellTransformation = CellTemplate.executing;
+ if (templateConfig) {
+ component.columns[0].customTemplateConfig = templateConfig;
+ }
+ component.data[0].cdExecuting = state;
+ fixture.detectChanges();
+
+ const elements = fixture.debugElement
+ .query(By.css('datatable-body-row datatable-body-cell'))
+ .queryAll(By.css('span'));
+ expect(elements.length).toBe(2);
+
+ // Value
+ const valueElement = elements[0];
+ if (templateConfig?.valueClass) {
+ templateConfig.valueClass.split(' ').forEach((clz) => {
+ expect(valueElement.classes).toHaveProperty(clz);
+ });
+ }
+ expect(valueElement.nativeElement.textContent.trim()).toBe(`${value}`);
+ // Executing state
+ const executingElement = elements[1];
+ if (templateConfig?.executingClass) {
+ templateConfig.executingClass.split(' ').forEach((clz) => {
+ expect(executingElement.classes).toHaveProperty(clz);
+ });
+ }
+ expect(executingElement.nativeElement.textContent.trim()).toBe(`(${state})`);
+ };
+
+ it('should display executing template', () => {
+ testExecutingTemplate();
+ });
+
+ it('should display executing template with custom classes', () => {
+ testExecutingTemplate({ valueClass: 'a b', executingClass: 'c d' });
+ });
+ });
+
+ describe('test unselect functionality of rows', () => {
+ beforeEach(() => {
+ component.autoReload = -1;
+ component.selectionType = 'single';
+ fixture.detectChanges();
+ });
+
+ it('should unselect row on clicking on it again', () => {
+ const rowCellDebugElement = fixture.debugElement.query(By.css('datatable-body-cell'));
+
+ rowCellDebugElement.triggerEventHandler('click', null);
+ expect(component.selection.selected.length).toEqual(1);
+
+ rowCellDebugElement.triggerEventHandler('click', null);
+ expect(component.selection.selected.length).toEqual(0);
+ });
+ });
+
+ describe('reload data', () => {
+ beforeEach(() => {
+ component.ngOnInit();
+ component.data = [];
+ component['updating'] = false;
+ });
+
+ it('should call fetchData callback function', () => {
+ component.fetchData.subscribe((context: any) => {
+ expect(context instanceof CdTableFetchDataContext).toBeTruthy();
+ });
+ component.reloadData();
+ });
+
+ it('should call error function', () => {
+ component.data = createFakeData(5);
+ component.fetchData.subscribe((context: any) => {
+ context.error();
+ expect(component.status.type).toBe('danger');
+ expect(component.data.length).toBe(0);
+ expect(component.loadingIndicator).toBeFalsy();
+ expect(component['updating']).toBeFalsy();
+ });
+ component.reloadData();
+ });
+
+ it('should call error function with custom config', () => {
+ component.data = createFakeData(10);
+ component.fetchData.subscribe((context: any) => {
+ context.errorConfig.resetData = false;
+ context.errorConfig.displayError = false;
+ context.error();
+ expect(component.status.type).toBe('danger');
+ expect(component.data.length).toBe(10);
+ expect(component.loadingIndicator).toBeFalsy();
+ expect(component['updating']).toBeFalsy();
+ });
+ component.reloadData();
+ });
+
+ it('should update selection on refresh - "onChange"', () => {
+ spyOn(component, 'onSelect').and.callThrough();
+ component.data = createFakeData(10);
+ component.selection.selected = [_.clone(component.data[1])];
+ component.updateSelectionOnRefresh = 'onChange';
+ component.updateSelected();
+ expect(component.onSelect).toHaveBeenCalledTimes(0);
+ component.data[1].d = !component.data[1].d;
+ component.updateSelected();
+ expect(component.onSelect).toHaveBeenCalled();
+ });
+
+ it('should update selection on refresh - "always"', () => {
+ spyOn(component, 'onSelect').and.callThrough();
+ component.data = createFakeData(10);
+ component.selection.selected = [_.clone(component.data[1])];
+ component.updateSelectionOnRefresh = 'always';
+ component.updateSelected();
+ expect(component.onSelect).toHaveBeenCalled();
+ component.data[1].d = !component.data[1].d;
+ component.updateSelected();
+ expect(component.onSelect).toHaveBeenCalled();
+ });
+
+ it('should update selection on refresh - "never"', () => {
+ spyOn(component, 'onSelect').and.callThrough();
+ component.data = createFakeData(10);
+ component.selection.selected = [_.clone(component.data[1])];
+ component.updateSelectionOnRefresh = 'never';
+ component.updateSelected();
+ expect(component.onSelect).toHaveBeenCalledTimes(0);
+ component.data[1].d = !component.data[1].d;
+ component.updateSelected();
+ expect(component.onSelect).toHaveBeenCalledTimes(0);
+ });
+
+ afterEach(() => {
+ clearLocalStorage();
+ });
+ });
+
+ describe('useCustomClass', () => {
+ beforeEach(() => {
+ component.customCss = {
+ 'badge badge-danger': 'active',
+ 'secret secret-number': 123.456,
+ btn: (v) => _.isString(v) && v.startsWith('http'),
+ secure: (v) => _.isString(v) && v.startsWith('https')
+ };
+ });
+
+ it('should throw an error if custom classes are not set', () => {
+ component.customCss = undefined;
+ expect(() => component.useCustomClass('active')).toThrowError('Custom classes are not set!');
+ });
+
+ it('should not return any class', () => {
+ ['', 'something', 123, { complex: 1 }, [1, 2, 3]].forEach((value) =>
+ expect(component.useCustomClass(value)).toBe(undefined)
+ );
+ });
+
+ it('should match a string and return the corresponding class', () => {
+ expect(component.useCustomClass('active')).toBe('badge badge-danger');
+ });
+
+ it('should match a number and return the corresponding class', () => {
+ expect(component.useCustomClass(123.456)).toBe('secret secret-number');
+ });
+
+ it('should match against a function and return the corresponding class', () => {
+ expect(component.useCustomClass('http://no.ssl')).toBe('btn');
+ });
+
+ it('should match against multiple functions and return the corresponding classes', () => {
+ expect(component.useCustomClass('https://secure.it')).toBe('btn secure');
+ });
+ });
+
+ describe('test expand and collapse feature', () => {
+ beforeEach(() => {
+ spyOn(component.setExpandedRow, 'emit');
+ component.table = {
+ rowDetail: { collapseAllRows: jest.fn(), toggleExpandRow: jest.fn() }
+ } as any;
+
+ // Setup table
+ component.identifier = 'a';
+ component.data = createFakeData(10);
+
+ // Select item
+ component.expanded = _.clone(component.data[1]);
+ });
+
+ describe('update expanded on refresh', () => {
+ const updateExpendedOnState = (state: 'always' | 'never' | 'onChange') => {
+ component.updateExpandedOnRefresh = state;
+ component.updateExpanded();
+ };
+
+ beforeEach(() => {
+ // Mock change
+ component.data[1].b = 'test';
+ });
+
+ it('refreshes "always"', () => {
+ updateExpendedOnState('always');
+ expect(component.expanded.b).toBe('test');
+ expect(component.setExpandedRow.emit).toHaveBeenCalled();
+ });
+
+ it('refreshes "onChange"', () => {
+ updateExpendedOnState('onChange');
+ expect(component.expanded.b).toBe('test');
+ expect(component.setExpandedRow.emit).toHaveBeenCalled();
+ });
+
+ it('does not refresh "onChange" if data is equal', () => {
+ component.data[1].b = 10; // Reverts change
+ updateExpendedOnState('onChange');
+ expect(component.expanded.b).toBe(10);
+ expect(component.setExpandedRow.emit).not.toHaveBeenCalled();
+ });
+
+ it('"never" refreshes', () => {
+ updateExpendedOnState('never');
+ expect(component.expanded.b).toBe(10);
+ expect(component.setExpandedRow.emit).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should open the table details and close other expanded rows', () => {
+ component.toggleExpandRow(component.expanded, false, new Event('click'));
+ expect(component.expanded).toEqual({ a: 1, b: 10, c: true });
+ expect(component.table.rowDetail.collapseAllRows).toHaveBeenCalled();
+ expect(component.setExpandedRow.emit).toHaveBeenCalledWith(component.expanded);
+ expect(component.table.rowDetail.toggleExpandRow).toHaveBeenCalled();
+ });
+
+ it('should close the current table details expansion', () => {
+ component.toggleExpandRow(component.expanded, true, new Event('click'));
+ expect(component.expanded).toBeUndefined();
+ expect(component.setExpandedRow.emit).toHaveBeenCalledWith(undefined);
+ expect(component.table.rowDetail.toggleExpandRow).toHaveBeenCalled();
+ });
+
+ it('should not select the row when the row is expanded', () => {
+ expect(component.selection.selected).toEqual([]);
+ component.toggleExpandRow(component.data[1], false, new Event('click'));
+ expect(component.selection.selected).toEqual([]);
+ });
+
+ it('should not change selection when expanding different row', () => {
+ expect(component.selection.selected).toEqual([]);
+ expect(component.expanded).toEqual(component.data[1]);
+ component.selection.selected = [component.data[2]];
+ component.toggleExpandRow(component.data[3], false, new Event('click'));
+ expect(component.selection.selected).toEqual([component.data[2]]);
+ expect(component.expanded).toEqual(component.data[3]);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
new file mode 100644
index 000000000..6a37468c2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
@@ -0,0 +1,927 @@
+import {
+ AfterContentChecked,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnDestroy,
+ OnInit,
+ Output,
+ PipeTransform,
+ SimpleChanges,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+
+import {
+ DatatableComponent,
+ getterForProp,
+ SortDirection,
+ SortPropDir,
+ TableColumnProp
+} from '@swimlane/ngx-datatable';
+import _ from 'lodash';
+import { Observable, of, Subject, Subscription } from 'rxjs';
+
+import { TableStatus } from '~/app/shared/classes/table-status';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter';
+import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { PageInfo } from '~/app/shared/models/cd-table-paging';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CdUserConfig } from '~/app/shared/models/cd-user-config';
+import { TimerService } from '~/app/shared/services/timer.service';
+
+@Component({
+ selector: 'cd-table',
+ templateUrl: './table.component.html',
+ styleUrls: ['./table.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy {
+ @ViewChild(DatatableComponent, { static: true })
+ table: DatatableComponent;
+ @ViewChild('tableCellBoldTpl', { static: true })
+ tableCellBoldTpl: TemplateRef<any>;
+ @ViewChild('sparklineTpl', { static: true })
+ sparklineTpl: TemplateRef<any>;
+ @ViewChild('routerLinkTpl', { static: true })
+ routerLinkTpl: TemplateRef<any>;
+ @ViewChild('checkIconTpl', { static: true })
+ checkIconTpl: TemplateRef<any>;
+ @ViewChild('perSecondTpl', { static: true })
+ perSecondTpl: TemplateRef<any>;
+ @ViewChild('executingTpl', { static: true })
+ executingTpl: TemplateRef<any>;
+ @ViewChild('classAddingTpl', { static: true })
+ classAddingTpl: TemplateRef<any>;
+ @ViewChild('badgeTpl', { static: true })
+ badgeTpl: TemplateRef<any>;
+ @ViewChild('mapTpl', { static: true })
+ mapTpl: TemplateRef<any>;
+ @ViewChild('truncateTpl', { static: true })
+ truncateTpl: TemplateRef<any>;
+ @ViewChild('rowDetailsTpl', { static: true })
+ rowDetailsTpl: TemplateRef<any>;
+
+ // This is the array with the items to be shown.
+ @Input()
+ data: any[];
+ // Each item -> { prop: 'attribute name', name: 'display name' }
+ @Input()
+ columns: CdTableColumn[];
+ // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
+ @Input()
+ sorts?: SortPropDir[];
+ // Method used for setting column widths.
+ @Input()
+ columnMode? = 'flex';
+ // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
+ @Input()
+ onlyActionHeader? = false;
+ // Display the tool header, including reload button, pagination and search fields?
+ @Input()
+ toolHeader? = true;
+ // Display search field inside tool header?
+ @Input()
+ searchField? = true;
+ // Display the table header?
+ @Input()
+ header? = true;
+ // Display the table footer?
+ @Input()
+ footer? = true;
+ // Page size to show. Set to 0 to show unlimited number of rows.
+ @Input()
+ limit? = 10;
+ @Input()
+ maxLimit? = 9999;
+ // Has the row details?
+ @Input()
+ hasDetails = false;
+
+ /**
+ * Auto reload time in ms - per default every 5s
+ * You can set it to 0, undefined or false to disable the auto reload feature in order to
+ * trigger 'fetchData' if the reload button is clicked.
+ * You can set it to a negative number to, on top of disabling the auto reload,
+ * prevent triggering fetchData when initializing the table.
+ */
+ @Input()
+ autoReload = 5000;
+
+ // Which row property is unique for a row. If the identifier is not specified in any
+ // column, then the property name of the first column is used. Defaults to 'id'.
+ @Input()
+ identifier = 'id';
+ // If 'true', then the specified identifier is used anyway, although it is not specified
+ // in any column. Defaults to 'false'.
+ @Input()
+ forceIdentifier = false;
+ // Allows other components to specify which type of selection they want,
+ // e.g. 'single' or 'multi'.
+ @Input()
+ selectionType: string = undefined;
+ // By default selected item details will be updated on table refresh, if data has changed
+ @Input()
+ updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
+ // By default expanded item details will be updated on table refresh, if data has changed
+ @Input()
+ updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
+
+ @Input()
+ autoSave = true;
+
+ // Enable this in order to search through the JSON of any used object.
+ @Input()
+ searchableObjects = false;
+
+ // Only needed to set if the classAddingTpl is used
+ @Input()
+ customCss?: { [css: string]: number | string | ((any: any) => boolean) };
+
+ // Columns that aren't displayed but can be used as filters
+ @Input()
+ extraFilterableColumns: CdTableColumn[] = [];
+
+ @Input()
+ status = new TableStatus();
+
+ // Support server-side pagination/sorting/etc.
+ @Input()
+ serverSide = false;
+
+ /*
+ Only required when serverSide is enabled.
+ It should be provided by the server via "X-Total-Count" HTTP Header
+ */
+ @Input()
+ count = 0;
+
+ /**
+ * Should be a function to update the input data if undefined nothing will be triggered
+ *
+ * Sometimes it's useful to only define fetchData once.
+ * Example:
+ * Usage of multiple tables with data which is updated by the same function
+ * What happens:
+ * The function is triggered through one table and all tables will update
+ */
+ @Output()
+ fetchData = new EventEmitter<CdTableFetchDataContext>();
+
+ /**
+ * This should be defined if you need access to the selection object.
+ *
+ * Each time the table selection changes, this will be triggered and
+ * the new selection object will be sent.
+ *
+ * @memberof TableComponent
+ */
+ @Output()
+ updateSelection = new EventEmitter();
+
+ @Output()
+ setExpandedRow = new EventEmitter();
+
+ /**
+ * This should be defined if you need access to the applied column filters.
+ *
+ * Each time the column filters changes, this will be triggered and
+ * the column filters change event will be sent.
+ *
+ * @memberof TableComponent
+ */
+ @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
+
+ /**
+ * Use this variable to access the selected row(s).
+ */
+ selection = new CdTableSelection();
+
+ /**
+ * Use this variable to access the expanded row
+ */
+ expanded: any = undefined;
+
+ /**
+ * To prevent making changes to the original columns list, that might change
+ * how the table is renderer a second time, we now clone that list into a
+ * local variable and only use the clone.
+ */
+ localColumns: CdTableColumn[];
+ tableColumns: CdTableColumn[];
+ icons = Icons;
+ cellTemplates: {
+ [key: string]: TemplateRef<any>;
+ } = {};
+ search = '';
+ rows: any[] = [];
+ loadingIndicator = true;
+ paginationClasses = {
+ pagerLeftArrow: Icons.leftArrowDouble,
+ pagerRightArrow: Icons.rightArrowDouble,
+ pagerPrevious: Icons.leftArrow,
+ pagerNext: Icons.rightArrow
+ };
+ userConfig: CdUserConfig = {};
+ tableName: string;
+ localStorage = window.localStorage;
+ private saveSubscriber: Subscription;
+ private reloadSubscriber: Subscription;
+ private updating = false;
+
+ // Internal variable to check if it is necessary to recalculate the
+ // table columns after the browser window has been resized.
+ private currentWidth: number;
+
+ columnFilters: CdTableColumnFilter[] = [];
+ selectedFilter: CdTableColumnFilter;
+ get columnFiltered(): boolean {
+ return _.some(this.columnFilters, (filter) => {
+ return filter.value !== undefined;
+ });
+ }
+
+ constructor(
+ // private ngZone: NgZone,
+ private cdRef: ChangeDetectorRef,
+ private timerService: TimerService
+ ) {}
+
+ static prepareSearch(search: string) {
+ search = search.toLowerCase().replace(/,/g, '');
+ if (search.match(/['"][^'"]+['"]/)) {
+ search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
+ return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
+ });
+ }
+ return search.split(' ').filter((word) => word);
+ }
+
+ ngOnInit() {
+ this.localColumns = _.clone(this.columns);
+ // debounce reloadData method so that search doesn't run api requests
+ // for every keystroke
+ if (this.serverSide) {
+ this.reloadData = _.debounce(this.reloadData, 1000);
+ }
+
+ // ngx-datatable triggers calculations each time mouse enters a row,
+ // this will prevent that.
+ this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation());
+ this._addTemplates();
+ if (!this.sorts) {
+ // Check whether the specified identifier exists.
+ const exists = _.findIndex(this.localColumns, ['prop', this.identifier]) !== -1;
+ // Auto-build the sorting configuration. If the specified identifier doesn't exist,
+ // then use the property of the first column.
+ this.sorts = this.createSortingDefinition(
+ exists ? this.identifier : this.localColumns[0].prop + ''
+ );
+ // If the specified identifier doesn't exist and it is not forced to use it anyway,
+ // then use the property of the first column.
+ if (!exists && !this.forceIdentifier) {
+ this.identifier = this.localColumns[0].prop + '';
+ }
+ }
+
+ this.initUserConfig();
+ this.localColumns.forEach((c) => {
+ if (c.cellTransformation) {
+ c.cellTemplate = this.cellTemplates[c.cellTransformation];
+ }
+ if (!c.flexGrow) {
+ c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
+ }
+ if (!c.resizeable) {
+ c.resizeable = false;
+ }
+ });
+
+ this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows
+ this.initCheckboxColumn();
+ this.filterHiddenColumns();
+ this.initColumnFilters();
+ this.updateColumnFilterOptions();
+ // Notify all subscribers to reset their current selection.
+ this.updateSelection.emit(new CdTableSelection());
+ // Load the data table content every N ms or at least once.
+ // Force showing the loading indicator if there are subscribers to the fetchData
+ // event. This is necessary because it has been set to False in useData() when
+ // this method was triggered by ngOnChanges().
+ if (this.fetchData.observers.length > 0) {
+ this.loadingIndicator = true;
+ }
+ if (_.isInteger(this.autoReload) && this.autoReload > 0) {
+ this.reloadSubscriber = this.timerService
+ .get(() => of(0), this.autoReload)
+ .subscribe(() => {
+ this.reloadData();
+ });
+ } else if (!this.autoReload) {
+ this.reloadData();
+ } else {
+ this.useData();
+ }
+
+ if (this.selectionType === 'single') {
+ this.table.selectCheck = this.singleSelectCheck.bind(this);
+ }
+ }
+
+ initUserConfig() {
+ if (this.autoSave) {
+ this.tableName = this._calculateUniqueTableName(this.localColumns);
+ this._loadUserConfig();
+ this._initUserConfigAutoSave();
+ }
+ if (!this.userConfig.limit) {
+ this.userConfig.limit = this.limit;
+ }
+ if (!(this.userConfig.offset >= 0)) {
+ this.userConfig.offset = this.table.offset;
+ }
+ if (!this.userConfig.search) {
+ this.userConfig.search = this.search;
+ }
+ if (!this.userConfig.sorts) {
+ this.userConfig.sorts = this.sorts;
+ }
+ if (!this.userConfig.columns) {
+ this.updateUserColumns();
+ } else {
+ this.userConfig.columns.forEach((col) => {
+ for (let i = 0; i < this.localColumns.length; i++) {
+ if (this.localColumns[i].prop === col.prop) {
+ this.localColumns[i].isHidden = col.isHidden;
+ }
+ }
+ });
+ }
+ }
+
+ _calculateUniqueTableName(columns: any[]) {
+ const stringToNumber = (s: string) => {
+ if (!_.isString(s)) {
+ return 0;
+ }
+ let result = 0;
+ for (let i = 0; i < s.length; i++) {
+ result += s.charCodeAt(i) * i;
+ }
+ return result;
+ };
+ return columns
+ .reduce(
+ (result, value, index) =>
+ (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
+ 0
+ )
+ .toString();
+ }
+
+ _loadUserConfig() {
+ const loaded = this.localStorage.getItem(this.tableName);
+ if (loaded) {
+ this.userConfig = JSON.parse(loaded);
+ }
+ }
+
+ _initUserConfigAutoSave() {
+ const source: Observable<any> = new Observable(this._initUserConfigProxy.bind(this));
+ this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
+ }
+
+ _initUserConfigProxy(observer: Subject<any>) {
+ this.userConfig = new Proxy(this.userConfig, {
+ set(config, prop: string, value) {
+ config[prop] = value;
+ observer.next(config);
+ return true;
+ }
+ });
+ }
+
+ _saveUserConfig(config: any) {
+ this.localStorage.setItem(this.tableName, JSON.stringify(config));
+ }
+
+ updateUserColumns() {
+ this.userConfig.columns = this.localColumns.map((c) => ({
+ prop: c.prop,
+ name: c.name,
+ isHidden: !!c.isHidden
+ }));
+ }
+
+ /**
+ * Add a column containing a checkbox if selectionType is 'multiClick'.
+ */
+ initCheckboxColumn() {
+ if (this.selectionType === 'multiClick') {
+ this.localColumns.unshift({
+ prop: undefined,
+ resizeable: false,
+ sortable: false,
+ draggable: false,
+ checkboxable: true,
+ canAutoResize: false,
+ cellClass: 'cd-datatable-checkbox',
+ width: 30
+ });
+ }
+ }
+
+ /**
+ * Add a column to expand and collapse the table row if it 'hasDetails'
+ */
+ initExpandCollapseColumn() {
+ if (this.hasDetails) {
+ this.localColumns.unshift({
+ prop: undefined,
+ resizeable: false,
+ sortable: false,
+ draggable: false,
+ isHidden: false,
+ canAutoResize: false,
+ cellClass: 'cd-datatable-expand-collapse',
+ width: 40,
+ cellTemplate: this.rowDetailsTpl
+ });
+ }
+ }
+
+ filterHiddenColumns() {
+ this.tableColumns = this.localColumns.filter((c) => !c.isHidden);
+ }
+
+ initColumnFilters() {
+ let filterableColumns = _.filter(this.localColumns, { filterable: true });
+ filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
+ this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
+ return {
+ column: col,
+ options: [],
+ value: col.filterInitValue
+ ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
+ : undefined
+ };
+ });
+ this.selectedFilter = _.first(this.columnFilters);
+ }
+
+ private createColumnFilterOption(
+ value: any,
+ pipe?: PipeTransform
+ ): { raw: string; formatted: string } {
+ return {
+ raw: _.toString(value),
+ formatted: pipe ? pipe.transform(value) : _.toString(value)
+ };
+ }
+
+ updateColumnFilterOptions() {
+ // update all possible values in a column
+ this.columnFilters.forEach((filter) => {
+ let values: any[] = [];
+
+ if (_.isUndefined(filter.column.filterOptions)) {
+ // only allow types that can be easily converted into string
+ const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
+ return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
+ });
+ values = _.sortedUniq(pre.sort());
+ } else {
+ values = filter.column.filterOptions;
+ }
+
+ const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
+
+ // In case a previous value is not available anymore
+ if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
+ filter.value = undefined;
+ }
+
+ filter.options = options;
+ });
+ }
+
+ onSelectFilter(filter: CdTableColumnFilter) {
+ this.selectedFilter = filter;
+ }
+
+ onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
+ filter.value = _.isEqual(filter.value, option) ? undefined : option;
+ this.updateFilter();
+ }
+
+ doColumnFiltering() {
+ const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
+ let data = [...this.data];
+ let dataOut: any[] = [];
+ this.columnFilters.forEach((filter) => {
+ if (filter.value === undefined) {
+ return;
+ }
+ appliedFilters.push({
+ name: filter.column.name,
+ prop: filter.column.prop,
+ value: filter.value
+ });
+ // Separate data to filtered and filtered-out parts.
+ const parts = _.partition(data, (row) => {
+ // Use getter from ngx-datatable to handle props like 'sys_api.size'
+ const valueGetter = getterForProp(filter.column.prop);
+ const value = valueGetter(row, filter.column.prop);
+ if (_.isUndefined(filter.column.filterPredicate)) {
+ // By default, test string equal
+ return `${value}` === filter.value.raw;
+ } else {
+ // Use custom function to filter
+ return filter.column.filterPredicate(row, filter.value.raw);
+ }
+ });
+ data = parts[0];
+ dataOut = [...dataOut, ...parts[1]];
+ });
+
+ this.columnFiltersChanged.emit({
+ filters: appliedFilters,
+ data: data,
+ dataOut: dataOut
+ });
+
+ // Remove the selection if previously-selected rows are filtered out.
+ _.forEach(this.selection.selected, (selectedItem) => {
+ if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
+ this.selection = new CdTableSelection();
+ this.onSelect(this.selection);
+ }
+ });
+ return data;
+ }
+
+ ngOnDestroy() {
+ if (this.reloadSubscriber) {
+ this.reloadSubscriber.unsubscribe();
+ }
+ if (this.saveSubscriber) {
+ this.saveSubscriber.unsubscribe();
+ }
+ }
+
+ ngAfterContentChecked() {
+ // If the data table is not visible, e.g. another tab is active, and the
+ // browser window gets resized, the table and its columns won't get resized
+ // automatically if the tab gets visible again.
+ // https://github.com/swimlane/ngx-datatable/issues/193
+ // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
+ if (this.table && this.table.element.clientWidth !== this.currentWidth) {
+ this.currentWidth = this.table.element.clientWidth;
+ // Recalculate the sizes of the grid.
+ this.table.recalculate();
+ // Mark the datatable as changed, Angular's change-detection will
+ // do the rest for us => the grid will be redrawn.
+ // Note, the ChangeDetectorRef variable is private, so we need to
+ // use this workaround to access it and make TypeScript happy.
+ const cdRef = _.get(this.table, 'cd');
+ cdRef.markForCheck();
+ }
+ }
+
+ _addTemplates() {
+ this.cellTemplates.bold = this.tableCellBoldTpl;
+ this.cellTemplates.checkIcon = this.checkIconTpl;
+ this.cellTemplates.sparkline = this.sparklineTpl;
+ this.cellTemplates.routerLink = this.routerLinkTpl;
+ this.cellTemplates.perSecond = this.perSecondTpl;
+ this.cellTemplates.executing = this.executingTpl;
+ this.cellTemplates.classAdding = this.classAddingTpl;
+ this.cellTemplates.badge = this.badgeTpl;
+ this.cellTemplates.map = this.mapTpl;
+ this.cellTemplates.truncate = this.truncateTpl;
+ }
+
+ useCustomClass(value: any): string {
+ if (!this.customCss) {
+ throw new Error('Custom classes are not set!');
+ }
+ const classes = Object.keys(this.customCss);
+ const css = Object.values(this.customCss)
+ .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
+ .filter((x) => x)
+ .join(' ');
+ return _.isEmpty(css) ? undefined : css;
+ }
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes.data && changes.data.currentValue) {
+ this.useData();
+ }
+ }
+
+ setLimit(e: any) {
+ const value = Number(e.target.value);
+ if (value > 0) {
+ if (this.maxLimit && value > this.maxLimit) {
+ this.userConfig.limit = this.maxLimit;
+ // change input field to maxLimit
+ e.srcElement.value = this.maxLimit;
+ } else {
+ this.userConfig.limit = value;
+ }
+ }
+ if (this.serverSide) {
+ this.reloadData();
+ }
+ }
+
+ reloadData() {
+ if (!this.updating) {
+ this.status = new TableStatus();
+ const context = new CdTableFetchDataContext(() => {
+ // Do we have to display the error panel?
+ if (!!context.errorConfig.displayError) {
+ this.status = new TableStatus('danger', $localize`Failed to load data.`);
+ }
+ // Force data table to show no data?
+ if (context.errorConfig.resetData) {
+ this.data = [];
+ }
+ // Stop the loading indicator and reset the data table
+ // to the correct state.
+ this.useData();
+ });
+ context.pageInfo.offset = this.userConfig.offset;
+ context.pageInfo.limit = this.userConfig.limit;
+ context.search = this.userConfig.search;
+ if (this.userConfig.sorts?.length) {
+ const sort = this.userConfig.sorts[0];
+ context.sort = `${sort.dir === 'desc' ? '-' : '+'}${sort.prop}`;
+ }
+ this.fetchData.emit(context);
+ this.updating = true;
+ }
+ }
+
+ refreshBtn() {
+ this.loadingIndicator = true;
+ this.reloadData();
+ }
+
+ changePage(pageInfo: PageInfo) {
+ this.userConfig.offset = pageInfo.offset;
+ this.userConfig.limit = pageInfo.limit;
+ if (this.serverSide) {
+ this.reloadData();
+ }
+ }
+ rowIdentity() {
+ return (row: any) => {
+ const id = row[this.identifier];
+ if (_.isUndefined(id)) {
+ throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
+ }
+ return id;
+ };
+ }
+
+ useData() {
+ if (!this.data) {
+ return; // Wait for data
+ }
+ this.updateColumnFilterOptions();
+ this.updateFilter();
+ this.reset();
+ this.updateSelected();
+ this.updateExpanded();
+ }
+
+ /**
+ * Reset the data table to correct state. This includes:
+ * - Disable loading indicator
+ * - Reset 'Updating' flag
+ */
+ reset() {
+ this.loadingIndicator = false;
+ this.updating = false;
+ }
+
+ /**
+ * After updating the data, we have to update the selected items
+ * because details may have changed,
+ * or some selected items may have been removed.
+ */
+ updateSelected() {
+ if (this.updateSelectionOnRefresh === 'never') {
+ return;
+ }
+ const newSelected = new Set();
+ this.selection.selected.forEach((selectedItem) => {
+ for (const row of this.data) {
+ if (selectedItem[this.identifier] === row[this.identifier]) {
+ newSelected.add(row);
+ }
+ }
+ });
+ const newSelectedArray = Array.from(newSelected.values());
+ if (
+ this.updateSelectionOnRefresh === 'onChange' &&
+ _.isEqual(this.selection.selected, newSelectedArray)
+ ) {
+ return;
+ }
+ this.selection.selected = newSelectedArray;
+ this.onSelect(this.selection);
+ }
+
+ updateExpanded() {
+ if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') {
+ return;
+ }
+
+ const expandedId = this.expanded[this.identifier];
+ const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]);
+
+ if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) {
+ return;
+ }
+
+ this.expanded = newExpanded;
+ this.setExpandedRow.emit(newExpanded);
+ }
+
+ onSelect($event: any) {
+ // Ensure we do not process DOM 'select' events.
+ // https://github.com/swimlane/ngx-datatable/issues/899
+ if (_.has($event, 'selected')) {
+ this.selection.selected = $event['selected'];
+ }
+ this.updateSelection.emit(_.clone(this.selection));
+ }
+
+ private singleSelectCheck(row: any) {
+ return this.selection.selected.indexOf(row) === -1;
+ }
+
+ toggleColumn(column: CdTableColumn) {
+ const prop: TableColumnProp = column.prop;
+ const hide = !column.isHidden;
+ if (hide && this.tableColumns.length === 1) {
+ column.isHidden = true;
+ return;
+ }
+ _.find(this.localColumns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
+ this.updateColumns();
+ }
+
+ updateColumns() {
+ this.updateUserColumns();
+ this.filterHiddenColumns();
+ const sortProp = this.userConfig.sorts[0].prop;
+ if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
+ this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
+ }
+ this.table.recalculate();
+ this.cdRef.detectChanges();
+ }
+
+ createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
+ return [
+ {
+ prop: prop,
+ dir: SortDirection.asc
+ }
+ ];
+ }
+
+ changeSorting({ sorts }: any) {
+ this.userConfig.sorts = sorts;
+ if (this.serverSide) {
+ this.userConfig.offset = 0;
+ this.reloadData();
+ }
+ }
+
+ onClearSearch() {
+ this.search = '';
+ this.updateFilter();
+ }
+
+ onClearFilters() {
+ this.columnFilters.forEach((filter) => {
+ filter.value = undefined;
+ });
+ this.selectedFilter = _.first(this.columnFilters);
+ this.updateFilter();
+ }
+
+ updateFilter() {
+ if (this.serverSide) {
+ if (this.userConfig.search !== this.search) {
+ // if we don't go back to the first page it will try load
+ // a page which could not exists with an especific search
+ this.userConfig.offset = 0;
+ this.userConfig.limit = this.limit;
+ this.userConfig.search = this.search;
+ this.updating = false;
+ this.reloadData();
+ }
+ this.rows = this.data;
+ } else {
+ let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
+
+ if (this.search.length > 0 && rows) {
+ const columns = this.localColumns.filter(
+ (c) => c.cellTransformation !== CellTemplate.sparkline
+ );
+ // update the rows
+ rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
+ // Whenever the filter changes, always go back to the first page
+ this.table.offset = 0;
+ }
+
+ this.rows = rows;
+ }
+ }
+
+ subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
+ if (currentSearch.length === 0 || data.length === 0) {
+ return data;
+ }
+ const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':');
+ const columnsClone = [...columns];
+ if (searchTerms.length === 2) {
+ columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
+ }
+ data = this.basicDataSearch(_.last(searchTerms), data, columns);
+ // Checks if user searches for column but he is still typing
+ return this.subSearch(data, currentSearch, columnsClone);
+ }
+
+ basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
+ if (searchTerm.length === 0) {
+ return rows;
+ }
+ return rows.filter((row) => {
+ return (
+ columns.filter((col) => {
+ let cellValue: any = _.get(row, col.prop);
+
+ if (!_.isUndefined(col.pipe)) {
+ cellValue = col.pipe.transform(cellValue);
+ }
+ if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
+ return false;
+ }
+
+ if (_.isArray(cellValue)) {
+ cellValue = cellValue.join(' ');
+ } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
+ cellValue = cellValue.toString();
+ }
+
+ if (_.isObjectLike(cellValue)) {
+ if (this.searchableObjects) {
+ cellValue = JSON.stringify(cellValue);
+ } else {
+ return false;
+ }
+ }
+
+ return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
+ }).length > 0
+ );
+ });
+ }
+
+ getRowClass() {
+ // Return the function used to populate a row's CSS classes.
+ return () => {
+ return {
+ clickable: !_.isUndefined(this.selectionType)
+ };
+ };
+ }
+
+ toggleExpandRow(row: any, isExpanded: boolean, event: any) {
+ event.stopPropagation();
+ if (!isExpanded) {
+ // If current row isn't expanded, collapse others
+ this.expanded = row;
+ this.table.rowDetail.collapseAllRows();
+ this.setExpandedRow.emit(row);
+ } else {
+ // If all rows are closed, emit undefined
+ this.expanded = undefined;
+ this.setExpandedRow.emit(undefined);
+ }
+ this.table.rowDetail.toggleExpandRow(row);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts
new file mode 100644
index 000000000..49b504fd6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts
@@ -0,0 +1,41 @@
+import { cdEncode, cdEncodeNot } from './cd-encode';
+
+describe('cdEncode', () => {
+ @cdEncode
+ class ClassA {
+ x2: string;
+ y2: string;
+
+ methodA(x1: string, @cdEncodeNot y1: string) {
+ this.x2 = x1;
+ this.y2 = y1;
+ }
+ }
+
+ class ClassB {
+ x2: string;
+ y2: string;
+
+ @cdEncode
+ methodB(x1: string, @cdEncodeNot y1: string) {
+ this.x2 = x1;
+ this.y2 = y1;
+ }
+ }
+
+ const word = 'a+b/c-d';
+
+ it('should encode all params of ClassA, with exception of y1', () => {
+ const a = new ClassA();
+ a.methodA(word, word);
+ expect(a.x2).toBe('a%2Bb%2Fc-d');
+ expect(a.y2).toBe(word);
+ });
+
+ it('should encode all params of methodB, with exception of y1', () => {
+ const b = new ClassB();
+ b.methodB(word, word);
+ expect(b.x2).toBe('a%2Bb%2Fc-d');
+ expect(b.y2).toBe(word);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts
new file mode 100644
index 000000000..afff2ec6d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts
@@ -0,0 +1,80 @@
+import _ from 'lodash';
+
+/**
+ * This decorator can be used in a class or method.
+ * It will encode all the string parameters of all the methods of a class
+ * or, if applied on a method, the specified method.
+ *
+ * @export
+ * @param {Function} [target=null]
+ * @returns {*}
+ */
+export function cdEncode(...args: any[]): any {
+ switch (args.length) {
+ case 1:
+ return encodeClass.apply(undefined, args);
+ case 3:
+ return encodeMethod.apply(undefined, args);
+ default:
+ throw new Error();
+ }
+}
+
+/**
+ * This decorator can be used in parameters only.
+ * It will exclude the parameter from being encode.
+ * This should be used in parameters that are going
+ * to be sent in the request's body.
+ *
+ * @export
+ * @param {Object} target
+ * @param {string} propertyKey
+ * @param {number} index
+ */
+export function cdEncodeNot(target: object, propertyKey: string, index: number) {
+ const metadataKey = `__ignore_${propertyKey}`;
+ if (Array.isArray(target[metadataKey])) {
+ target[metadataKey].push(index);
+ } else {
+ target[metadataKey] = [index];
+ }
+}
+
+function encodeClass(target: Function) {
+ for (const propertyName of Object.getOwnPropertyNames(target.prototype)) {
+ const descriptor = Object.getOwnPropertyDescriptor(target.prototype, propertyName);
+
+ const isMethod = descriptor.value instanceof Function;
+ const isConstructor = propertyName === 'constructor';
+ if (!isMethod || isConstructor) {
+ continue;
+ }
+
+ encodeMethod(target.prototype, propertyName, descriptor);
+ Object.defineProperty(target.prototype, propertyName, descriptor);
+ }
+}
+
+function encodeMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
+ if (descriptor === undefined) {
+ descriptor = Object.getOwnPropertyDescriptor(target, propertyKey);
+ }
+ const originalMethod = descriptor.value;
+
+ descriptor.value = function () {
+ const metadataKey = `__ignore_${propertyKey}`;
+ const indices: number[] = target[metadataKey] || [];
+ const args = [];
+
+ for (let i = 0; i < arguments.length; i++) {
+ if (_.isString(arguments[i]) && indices.indexOf(i) === -1) {
+ args[i] = encodeURIComponent(arguments[i]);
+ } else {
+ args[i] = arguments[i];
+ }
+ }
+
+ const result = originalMethod.apply(this, args);
+ return result;
+ };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts
new file mode 100644
index 000000000..9ef2078ec
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts
@@ -0,0 +1,90 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AutofocusDirective } from './autofocus.directive';
+
+@Component({
+ template: `
+ <form>
+ <input id="x" type="text" />
+ <input id="y" type="password" autofocus />
+ </form>
+ `
+})
+export class PasswordFormComponent {}
+
+@Component({
+ template: `
+ <form>
+ <input id="x" type="checkbox" [autofocus]="edit" />
+ <input id="y" type="text" />
+ </form>
+ `
+})
+export class CheckboxFormComponent {
+ public edit = true;
+}
+
+@Component({
+ template: `
+ <form>
+ <input id="x" type="text" [autofocus]="foo" />
+ </form>
+ `
+})
+export class TextFormComponent {
+ foo() {
+ return false;
+ }
+}
+
+describe('AutofocusDirective', () => {
+ configureTestBed({
+ declarations: [
+ AutofocusDirective,
+ CheckboxFormComponent,
+ PasswordFormComponent,
+ TextFormComponent
+ ]
+ });
+
+ it('should create an instance', () => {
+ const directive = new AutofocusDirective(null);
+ expect(directive).toBeTruthy();
+ });
+
+ it('should focus the password form field', () => {
+ const fixture: ComponentFixture<PasswordFormComponent> = TestBed.createComponent(
+ PasswordFormComponent
+ );
+ fixture.detectChanges();
+ const focused = fixture.debugElement.query(By.css(':focus'));
+ expect(focused.attributes.id).toBe('y');
+ expect(focused.attributes.type).toBe('password');
+ const element = document.getElementById('y');
+ expect(element === document.activeElement).toBeTruthy();
+ });
+
+ it('should focus the checkbox form field', () => {
+ const fixture: ComponentFixture<CheckboxFormComponent> = TestBed.createComponent(
+ CheckboxFormComponent
+ );
+ fixture.detectChanges();
+ const focused = fixture.debugElement.query(By.css(':focus'));
+ expect(focused.attributes.id).toBe('x');
+ expect(focused.attributes.type).toBe('checkbox');
+ const element = document.getElementById('x');
+ expect(element === document.activeElement).toBeTruthy();
+ });
+
+ it('should not focus the text form field', () => {
+ const fixture: ComponentFixture<TextFormComponent> = TestBed.createComponent(TextFormComponent);
+ fixture.detectChanges();
+ const focused = fixture.debugElement.query(By.css(':focus'));
+ expect(focused).toBeNull();
+ const element = document.getElementById('x');
+ expect(element !== document.activeElement).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts
new file mode 100644
index 000000000..dc34b9f3c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts
@@ -0,0 +1,28 @@
+import { AfterViewInit, Directive, ElementRef, Input } from '@angular/core';
+
+import _ from 'lodash';
+
+@Directive({
+ selector: '[autofocus]' // tslint:disable-line
+})
+export class AutofocusDirective implements AfterViewInit {
+ private focus = true;
+
+ constructor(private elementRef: ElementRef) {}
+
+ ngAfterViewInit() {
+ const el: HTMLInputElement = this.elementRef.nativeElement;
+ if (this.focus && _.isFunction(el.focus)) {
+ el.focus();
+ }
+ }
+
+ @Input()
+ public set autofocus(condition: any) {
+ if (_.isBoolean(condition)) {
+ this.focus = condition;
+ } else if (_.isFunction(condition)) {
+ this.focus = condition();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts
new file mode 100644
index 000000000..858becc45
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts
@@ -0,0 +1,12 @@
+import { DimlessBinaryPerSecondDirective } from './dimless-binary-per-second.directive';
+
+export class MockElementRef {
+ nativeElement: {};
+}
+
+describe('DimlessBinaryPerSecondDirective', () => {
+ it('should create an instance', () => {
+ const directive = new DimlessBinaryPerSecondDirective(new MockElementRef(), null, null, null);
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts
new file mode 100644
index 000000000..a90e2b8f8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts
@@ -0,0 +1,132 @@
+import {
+ Directive,
+ ElementRef,
+ EventEmitter,
+ HostListener,
+ Input,
+ OnInit,
+ Output
+} from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { DimlessBinaryPerSecondPipe } from '../pipes/dimless-binary-per-second.pipe';
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+ selector: '[cdDimlessBinaryPerSecond]'
+})
+export class DimlessBinaryPerSecondDirective implements OnInit {
+ @Output()
+ ngModelChange: EventEmitter<any> = new EventEmitter();
+
+ /**
+ * Event emitter for letting this directive know that the data has (asynchronously) been loaded
+ * and the value needs to be adapted by this directive.
+ */
+ @Input()
+ ngDataReady: EventEmitter<any>;
+
+ /**
+ * Minimum size in bytes.
+ * If user enter a value lower than <minBytes>,
+ * the model will automatically be update to <minBytes>.
+ *
+ * If <roundPower> is used, this value should be a power of <roundPower>.
+ *
+ * Example:
+ * Given minBytes=4096 (4KiB), if user type 1KiB, then model will be updated to 4KiB
+ */
+ @Input()
+ minBytes: number;
+
+ /**
+ * Maximum size in bytes.
+ * If user enter a value greater than <maxBytes>,
+ * the model will automatically be update to <maxBytes>.
+ *
+ * If <roundPower> is used, this value should be a power of <roundPower>.
+ *
+ * Example:
+ * Given maxBytes=3145728 (3MiB), if user type 4MiB, then model will be updated to 3MiB
+ */
+ @Input()
+ maxBytes: number;
+
+ /**
+ * Value will be rounded up the nearest power of <roundPower>
+ *
+ * Example:
+ * Given roundPower=2, if user type 7KiB, then model will be updated to 8KiB
+ * Given roundPower=2, if user type 5KiB, then model will be updated to 4KiB
+ */
+ @Input()
+ roundPower: number;
+
+ /**
+ * Default unit that should be used when user do not type a unit.
+ * By default, "MiB" will be used.
+ *
+ * Example:
+ * Given defaultUnit=null, if user type 7, then model will be updated to 7MiB
+ * Given defaultUnit=k, if user type 7, then model will be updated to 7KiB
+ */
+ @Input()
+ defaultUnit: string;
+
+ private el: HTMLInputElement;
+
+ constructor(
+ private elementRef: ElementRef,
+ private control: NgControl,
+ private dimlessBinaryPerSecondPipe: DimlessBinaryPerSecondPipe,
+ private formatter: FormatterService
+ ) {
+ this.el = this.elementRef.nativeElement;
+ }
+
+ ngOnInit() {
+ this.setValue(this.el.value);
+ if (this.ngDataReady) {
+ this.ngDataReady.subscribe(() => this.setValue(this.el.value));
+ }
+ }
+
+ setValue(value: string) {
+ if (/^[\d.]+$/.test(value)) {
+ value += this.defaultUnit || 'm';
+ }
+ const size = this.formatter.toBytes(value, 0);
+ const roundedSize = this.round(size);
+ this.el.value = this.dimlessBinaryPerSecondPipe.transform(roundedSize);
+ if (size !== null) {
+ this.ngModelChange.emit(this.el.value);
+ this.control.control.setValue(this.el.value);
+ } else {
+ this.ngModelChange.emit(null);
+ this.control.control.setValue(null);
+ }
+ }
+
+ round(size: number) {
+ if (size !== null && size !== 0) {
+ if (!_.isUndefined(this.minBytes) && size < this.minBytes) {
+ return this.minBytes;
+ }
+ if (!_.isUndefined(this.maxBytes) && size > this.maxBytes) {
+ return this.maxBytes;
+ }
+ if (!_.isUndefined(this.roundPower)) {
+ const power = Math.round(Math.log(size) / Math.log(this.roundPower));
+ return Math.pow(this.roundPower, power);
+ }
+ }
+ return size;
+ }
+
+ @HostListener('blur', ['$event.target.value'])
+ onBlur(value: string) {
+ this.setValue(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts
new file mode 100644
index 000000000..5822e7d97
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts
@@ -0,0 +1,12 @@
+import { DimlessBinaryDirective } from './dimless-binary.directive';
+
+export class MockElementRef {
+ nativeElement: {};
+}
+
+describe('DimlessBinaryDirective', () => {
+ it('should create an instance', () => {
+ const directive = new DimlessBinaryDirective(new MockElementRef(), null, null, null);
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts
new file mode 100644
index 000000000..1c27ae1ce
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts
@@ -0,0 +1,122 @@
+import {
+ Directive,
+ ElementRef,
+ EventEmitter,
+ HostListener,
+ Input,
+ OnInit,
+ Output
+} from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import _ from 'lodash';
+
+import { DimlessBinaryPipe } from '../pipes/dimless-binary.pipe';
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+ selector: '[cdDimlessBinary]'
+})
+export class DimlessBinaryDirective implements OnInit {
+ @Output()
+ ngModelChange: EventEmitter<any> = new EventEmitter();
+
+ /**
+ * Minimum size in bytes.
+ * If user enter a value lower than <minBytes>,
+ * the model will automatically be update to <minBytes>.
+ *
+ * If <roundPower> is used, this value should be a power of <roundPower>.
+ *
+ * Example:
+ * Given minBytes=4096 (4KiB), if user type 1KiB, then model will be updated to 4KiB
+ */
+ @Input()
+ minBytes: number;
+
+ /**
+ * Maximum size in bytes.
+ * If user enter a value greater than <maxBytes>,
+ * the model will automatically be update to <maxBytes>.
+ *
+ * If <roundPower> is used, this value should be a power of <roundPower>.
+ *
+ * Example:
+ * Given maxBytes=3145728 (3MiB), if user type 4MiB, then model will be updated to 3MiB
+ */
+ @Input()
+ maxBytes: number;
+
+ /**
+ * Value will be rounded up the nearest power of <roundPower>
+ *
+ * Example:
+ * Given roundPower=2, if user type 7KiB, then model will be updated to 8KiB
+ * Given roundPower=2, if user type 5KiB, then model will be updated to 4KiB
+ */
+ @Input()
+ roundPower: number;
+
+ /**
+ * Default unit that should be used when user do not type a unit.
+ * By default, "MiB" will be used.
+ *
+ * Example:
+ * Given defaultUnit=null, if user type 7, then model will be updated to 7MiB
+ * Given defaultUnit=k, if user type 7, then model will be updated to 7KiB
+ */
+ @Input()
+ defaultUnit: string;
+
+ private el: HTMLInputElement;
+
+ constructor(
+ private elementRef: ElementRef,
+ private control: NgControl,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private formatter: FormatterService
+ ) {
+ this.el = this.elementRef.nativeElement;
+ }
+
+ ngOnInit() {
+ this.setValue(this.el.value);
+ }
+
+ setValue(value: string) {
+ if (/^[\d.]+$/.test(value)) {
+ value += this.defaultUnit || 'm';
+ }
+ const size = this.formatter.toBytes(value);
+ const roundedSize = this.round(size);
+ this.el.value = this.dimlessBinaryPipe.transform(roundedSize);
+ if (size !== null) {
+ this.ngModelChange.emit(this.el.value);
+ this.control.control.setValue(this.el.value);
+ } else {
+ this.ngModelChange.emit(null);
+ this.control.control.setValue(null);
+ }
+ }
+
+ round(size: number) {
+ if (size !== null && size !== 0) {
+ if (!_.isUndefined(this.minBytes) && size < this.minBytes) {
+ return this.minBytes;
+ }
+ if (!_.isUndefined(this.maxBytes) && size > this.maxBytes) {
+ return this.maxBytes;
+ }
+ if (!_.isUndefined(this.roundPower)) {
+ const power = Math.round(Math.log(size) / Math.log(this.roundPower));
+ return Math.pow(this.roundPower, power);
+ }
+ }
+ return size;
+ }
+
+ @HostListener('blur', ['$event.target.value'])
+ onBlur(value: string) {
+ this.setValue(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts
new file mode 100644
index 000000000..00e5635d3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts
@@ -0,0 +1,53 @@
+import { NgModule } from '@angular/core';
+
+import { AutofocusDirective } from './autofocus.directive';
+import { DimlessBinaryPerSecondDirective } from './dimless-binary-per-second.directive';
+import { DimlessBinaryDirective } from './dimless-binary.directive';
+import { FormInputDisableDirective } from './form-input-disable.directive';
+import { FormLoadingDirective } from './form-loading.directive';
+import { FormScopeDirective } from './form-scope.directive';
+import { IopsDirective } from './iops.directive';
+import { MillisecondsDirective } from './milliseconds.directive';
+import { CdFormControlDirective } from './ng-bootstrap-form-validation/cd-form-control.directive';
+import { CdFormGroupDirective } from './ng-bootstrap-form-validation/cd-form-group.directive';
+import { CdFormValidationDirective } from './ng-bootstrap-form-validation/cd-form-validation.directive';
+import { PasswordButtonDirective } from './password-button.directive';
+import { StatefulTabDirective } from './stateful-tab.directive';
+import { TrimDirective } from './trim.directive';
+
+@NgModule({
+ imports: [],
+ declarations: [
+ AutofocusDirective,
+ DimlessBinaryDirective,
+ DimlessBinaryPerSecondDirective,
+ PasswordButtonDirective,
+ TrimDirective,
+ MillisecondsDirective,
+ IopsDirective,
+ FormLoadingDirective,
+ StatefulTabDirective,
+ FormInputDisableDirective,
+ FormScopeDirective,
+ CdFormControlDirective,
+ CdFormGroupDirective,
+ CdFormValidationDirective
+ ],
+ exports: [
+ AutofocusDirective,
+ DimlessBinaryDirective,
+ DimlessBinaryPerSecondDirective,
+ PasswordButtonDirective,
+ TrimDirective,
+ MillisecondsDirective,
+ IopsDirective,
+ FormLoadingDirective,
+ StatefulTabDirective,
+ FormInputDisableDirective,
+ FormScopeDirective,
+ CdFormControlDirective,
+ CdFormGroupDirective,
+ CdFormValidationDirective
+ ]
+})
+export class DirectivesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts
new file mode 100644
index 000000000..a79043b78
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts
@@ -0,0 +1,75 @@
+import { Component, DebugElement, Input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { Permission } from '../models/permissions';
+import { AuthStorageService } from '../services/auth-storage.service';
+import { FormInputDisableDirective } from './form-input-disable.directive';
+import { FormScopeDirective } from './form-scope.directive';
+
+@Component({
+ template: `
+ <form cdFormScope="osd">
+ <input type="checkbox" />
+ </form>
+ `
+})
+export class FormDisableComponent {}
+
+class MockFormScopeDirective {
+ @Input() cdFormScope = 'osd';
+}
+
+describe('FormInputDisableDirective', () => {
+ let fakePermissions: Permission;
+ let authStorageService: AuthStorageService;
+ let directive: FormInputDisableDirective;
+ let fixture: ComponentFixture<FormDisableComponent>;
+ let inputElement: DebugElement;
+ configureTestBed({
+ declarations: [FormScopeDirective, FormInputDisableDirective, FormDisableComponent]
+ });
+
+ beforeEach(() => {
+ directive = new FormInputDisableDirective(
+ new MockFormScopeDirective(),
+ new AuthStorageService(),
+ null
+ );
+
+ fakePermissions = {
+ create: false,
+ update: false,
+ read: false,
+ delete: false
+ };
+ authStorageService = TestBed.inject(AuthStorageService);
+ spyOn(authStorageService, 'getPermissions').and.callFake(() => ({
+ osd: fakePermissions
+ }));
+
+ fixture = TestBed.createComponent(FormDisableComponent);
+ inputElement = fixture.debugElement.query(By.css('input'));
+ });
+
+ afterEach(() => {
+ directive = null;
+ });
+
+ it('should create an instance', () => {
+ expect(directive).toBeTruthy();
+ });
+
+ it('should disable the input if update permission is false', () => {
+ fixture.detectChanges();
+ expect(inputElement.nativeElement.disabled).toBeTruthy();
+ });
+
+ it('should not disable the input if update permission is true', () => {
+ fakePermissions.update = true;
+ fakePermissions.read = false;
+ fixture.detectChanges();
+ expect(inputElement.nativeElement.disabled).toBeFalsy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts
new file mode 100644
index 000000000..3e3f83bc5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts
@@ -0,0 +1,27 @@
+import { AfterViewInit, Directive, ElementRef, Optional } from '@angular/core';
+
+import { Permissions } from '../models/permissions';
+import { AuthStorageService } from '../services/auth-storage.service';
+import { FormScopeDirective } from './form-scope.directive';
+
+@Directive({
+ selector:
+ 'input:not([cdNoFormInputDisable]), select:not([cdNoFormInputDisable]), button:not([cdNoFormInputDisable]), [cdFormInputDisable]'
+})
+export class FormInputDisableDirective implements AfterViewInit {
+ permissions: Permissions;
+
+ constructor(
+ @Optional() private formScope: FormScopeDirective,
+ private authStorageService: AuthStorageService,
+ private elementRef: ElementRef
+ ) {}
+
+ ngAfterViewInit() {
+ this.permissions = this.authStorageService.getPermissions();
+ const service_name = this.formScope?.cdFormScope;
+ if (service_name && !this.permissions?.[service_name]?.update) {
+ this.elementRef.nativeElement.disabled = true;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts
new file mode 100644
index 000000000..8bc3b05a2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts
@@ -0,0 +1,89 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AlertPanelComponent } from '../components/alert-panel/alert-panel.component';
+import { LoadingPanelComponent } from '../components/loading-panel/loading-panel.component';
+import { CdForm } from '../forms/cd-form';
+import { SharedModule } from '../shared.module';
+import { FormLoadingDirective } from './form-loading.directive';
+
+@Component({ selector: 'cd-test-cmp', template: '<span *cdFormLoading="loading">foo</span>' })
+class TestComponent extends CdForm {
+ constructor() {
+ super();
+ }
+}
+
+describe('FormLoadingDirective', () => {
+ let component: TestComponent;
+ let fixture: ComponentFixture<any>;
+
+ const expectShown = (elem: number, error: number, loading: number) => {
+ expect(fixture.debugElement.queryAll(By.css('span')).length).toEqual(elem);
+ expect(fixture.debugElement.queryAll(By.css('cd-alert-panel')).length).toEqual(error);
+ expect(fixture.debugElement.queryAll(By.css('cd-loading-panel')).length).toEqual(loading);
+ };
+
+ configureTestBed(
+ {
+ declarations: [TestComponent],
+ imports: [SharedModule, NgbAlertModule]
+ },
+ [LoadingPanelComponent, AlertPanelComponent]
+ );
+
+ afterEach(() => {
+ fixture = null;
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create an instance', () => {
+ const directive = new FormLoadingDirective(null, null, null);
+ expect(directive).toBeTruthy();
+ });
+
+ it('should show loading component by default', () => {
+ expectShown(0, 0, 1);
+
+ const alert = fixture.debugElement.nativeElement.querySelector('cd-loading-panel ngb-alert');
+ expect(alert.textContent).toBe('Loading form data...');
+ });
+
+ it('should show error component when calling loadingError()', () => {
+ component.loadingError();
+ fixture.detectChanges();
+
+ expectShown(0, 1, 0);
+
+ const alert = fixture.debugElement.nativeElement.querySelector(
+ 'cd-alert-panel .alert-panel-text'
+ );
+ expect(alert.textContent).toBe('Form data could not be loaded.');
+ });
+
+ it('should show original component when calling loadingReady()', () => {
+ component.loadingReady();
+ fixture.detectChanges();
+
+ expectShown(1, 0, 0);
+
+ const alert = fixture.debugElement.nativeElement.querySelector('span');
+ expect(alert.textContent).toBe('foo');
+ });
+
+ it('should show nothing when calling loadingNone()', () => {
+ component.loadingNone();
+ fixture.detectChanges();
+
+ expectShown(0, 0, 0);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts
new file mode 100644
index 000000000..e83614b84
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts
@@ -0,0 +1,51 @@
+import {
+ ComponentFactoryResolver,
+ Directive,
+ Input,
+ TemplateRef,
+ ViewContainerRef
+} from '@angular/core';
+
+import { AlertPanelComponent } from '../components/alert-panel/alert-panel.component';
+import { LoadingPanelComponent } from '../components/loading-panel/loading-panel.component';
+import { LoadingStatus } from '../forms/cd-form';
+
+@Directive({
+ selector: '[cdFormLoading]'
+})
+export class FormLoadingDirective {
+ constructor(
+ private templateRef: TemplateRef<any>,
+ private viewContainer: ViewContainerRef,
+ private componentFactoryResolver: ComponentFactoryResolver
+ ) {}
+
+ @Input('cdFormLoading') set cdFormLoading(condition: LoadingStatus) {
+ let factory: any;
+ let content: any;
+
+ this.viewContainer.clear();
+
+ switch (condition) {
+ case LoadingStatus.Loading:
+ factory = this.componentFactoryResolver.resolveComponentFactory(LoadingPanelComponent);
+ content = this.resolveNgContent($localize`Loading form data...`);
+ this.viewContainer.createComponent(factory, null, null, content);
+ break;
+ case LoadingStatus.Ready:
+ this.viewContainer.createEmbeddedView(this.templateRef);
+ break;
+ case LoadingStatus.Error:
+ factory = this.componentFactoryResolver.resolveComponentFactory(AlertPanelComponent);
+ content = this.resolveNgContent($localize`Form data could not be loaded.`);
+ const componentRef = this.viewContainer.createComponent(factory, null, null, content);
+ (<AlertPanelComponent>componentRef.instance).type = 'error';
+ break;
+ }
+ }
+
+ resolveNgContent(content: string) {
+ const element = document.createTextNode(content);
+ return [[element]];
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts
new file mode 100644
index 000000000..2cf882ece
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts
@@ -0,0 +1,8 @@
+import { FormScopeDirective } from './form-scope.directive';
+
+describe('UpdateOnlyDirective', () => {
+ it('should create an instance', () => {
+ const directive = new FormScopeDirective();
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts
new file mode 100644
index 000000000..8ae3f8489
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts
@@ -0,0 +1,8 @@
+import { Directive, Input } from '@angular/core';
+
+@Directive({
+ selector: '[cdFormScope]'
+})
+export class FormScopeDirective {
+ @Input() cdFormScope: any;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts
new file mode 100644
index 000000000..9c1641ded
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts
@@ -0,0 +1,8 @@
+import { IopsDirective } from './iops.directive';
+
+describe('IopsDirective', () => {
+ it('should create an instance', () => {
+ const directive = new IopsDirective(null, null);
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts
new file mode 100644
index 000000000..4faf69164
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts
@@ -0,0 +1,31 @@
+import { Directive, EventEmitter, HostListener, Input, OnInit } from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+ selector: '[cdIops]'
+})
+export class IopsDirective implements OnInit {
+ @Input()
+ ngDataReady: EventEmitter<any>;
+
+ constructor(private formatter: FormatterService, private ngControl: NgControl) {}
+
+ setValue(value: string): void {
+ const iops = this.formatter.toIops(value);
+ this.ngControl.control.setValue(`${iops} IOPS`);
+ }
+
+ ngOnInit(): void {
+ this.setValue(this.ngControl.value);
+ if (this.ngDataReady) {
+ this.ngDataReady.subscribe(() => this.setValue(this.ngControl.value));
+ }
+ }
+
+ @HostListener('blur', ['$event.target.value'])
+ onUpdate(value: string) {
+ this.setValue(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts
new file mode 100644
index 000000000..503802056
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts
@@ -0,0 +1,8 @@
+import { MillisecondsDirective } from './milliseconds.directive';
+
+describe('MillisecondsDirective', () => {
+ it('should create an instance', () => {
+ const directive = new MillisecondsDirective(null, null);
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts
new file mode 100644
index 000000000..d5bb4aff5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts
@@ -0,0 +1,31 @@
+import { Directive, EventEmitter, HostListener, Input, OnInit } from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+ selector: '[cdMilliseconds]'
+})
+export class MillisecondsDirective implements OnInit {
+ @Input()
+ ngDataReady: EventEmitter<any>;
+
+ constructor(private control: NgControl, private formatter: FormatterService) {}
+
+ setValue(value: string): void {
+ const ms = this.formatter.toMilliseconds(value);
+ this.control.control.setValue(`${ms} ms`);
+ }
+
+ ngOnInit(): void {
+ this.setValue(this.control.value);
+ if (this.ngDataReady) {
+ this.ngDataReady.subscribe(() => this.setValue(this.control.value));
+ }
+ }
+
+ @HostListener('blur', ['$event.target.value'])
+ onUpdate(value: string) {
+ this.setValue(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts
new file mode 100644
index 000000000..dd588ae7b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts
@@ -0,0 +1,37 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2017 Kevin Kipp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ *
+ * Based on https://github.com/third774/ng-bootstrap-form-validation
+ */
+
+import { NgForm } from '@angular/forms';
+
+import { CdFormControlDirective } from './cd-form-control.directive';
+
+describe('CdFormControlDirective', () => {
+ it('should create an instance', () => {
+ const directive = new CdFormControlDirective(new NgForm([], []));
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts
new file mode 100644
index 000000000..86afc72a5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts
@@ -0,0 +1,82 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2017 Kevin Kipp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ *
+ * Based on https://github.com/third774/ng-bootstrap-form-validation
+ */
+
+import { Directive, Host, HostBinding, Input, Optional, SkipSelf } from '@angular/core';
+import { ControlContainer, FormControl } from '@angular/forms';
+
+export function controlPath(name: string, parent: ControlContainer): string[] {
+ // tslint:disable-next-line:no-non-null-assertion
+ return [...parent.path!, name];
+}
+
+@Directive({
+ // tslint:disable-next-line:directive-selector
+ selector: '.form-control,.form-check-input,.custom-control-input'
+})
+export class CdFormControlDirective {
+ @Input()
+ formControlName: string;
+ @Input()
+ formControl: string;
+
+ @HostBinding('class.is-valid')
+ get validClass() {
+ if (!this.control) {
+ return false;
+ }
+ return this.control.valid && (this.control.touched || this.control.dirty);
+ }
+
+ @HostBinding('class.is-invalid')
+ get invalidClass() {
+ if (!this.control) {
+ return false;
+ }
+ return this.control.invalid && this.control.touched && this.control.dirty;
+ }
+
+ get path() {
+ return controlPath(this.formControlName, this.parent);
+ }
+
+ get control(): FormControl {
+ return this.formDirective && this.formDirective.getControl(this);
+ }
+
+ get formDirective(): any {
+ return this.parent ? this.parent.formDirective : null;
+ }
+
+ constructor(
+ // this value might be null, but we union type it as such until
+ // this issue is resolved: https://github.com/angular/angular/issues/25544
+ @Optional()
+ @Host()
+ @SkipSelf()
+ private parent: ControlContainer
+ ) {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts
new file mode 100644
index 000000000..40aa251cd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts
@@ -0,0 +1,37 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2017 Kevin Kipp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ *
+ * Based on https://github.com/third774/ng-bootstrap-form-validation
+ */
+
+import { ElementRef } from '@angular/core';
+
+import { CdFormGroupDirective } from './cd-form-group.directive';
+
+describe('CdFormGroupDirective', () => {
+ it('should create an instance', () => {
+ const directive = new CdFormGroupDirective(new ElementRef(null));
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts
new file mode 100644
index 000000000..5f6b11de1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts
@@ -0,0 +1,76 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2017 Kevin Kipp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ *
+ * Based on https://github.com/third774/ng-bootstrap-form-validation
+ */
+
+import {
+ ContentChildren,
+ Directive,
+ ElementRef,
+ HostBinding,
+ Input,
+ QueryList
+} from '@angular/core';
+import { FormControlName } from '@angular/forms';
+
+@Directive({
+ // tslint:disable-next-line:directive-selector
+ selector: '.form-group'
+})
+export class CdFormGroupDirective {
+ @ContentChildren(FormControlName)
+ formControlNames: QueryList<FormControlName>;
+
+ @Input()
+ validationDisabled = false;
+
+ @HostBinding('class.has-error')
+ get hasErrors() {
+ return (
+ this.formControlNames.some((c) => !c.valid && c.dirty && c.touched) &&
+ !this.validationDisabled
+ );
+ }
+
+ @HostBinding('class.has-success')
+ get hasSuccess() {
+ return (
+ !this.formControlNames.some((c) => !c.valid) &&
+ this.formControlNames.some((c) => c.dirty && c.touched) &&
+ !this.validationDisabled
+ );
+ }
+
+ constructor(private elRef: ElementRef) {}
+
+ get label() {
+ const label = this.elRef.nativeElement.querySelector('label');
+ return label && label.textContent ? label.textContent.trim() : 'This field';
+ }
+
+ get isDirtyAndTouched() {
+ return this.formControlNames.some((c) => c.dirty && c.touched);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts
new file mode 100644
index 000000000..c4b0f424b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts
@@ -0,0 +1,35 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2017 Kevin Kipp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ *
+ * Based on https://github.com/third774/ng-bootstrap-form-validation
+ */
+
+import { CdFormValidationDirective } from './cd-form-validation.directive';
+
+describe('CdFormValidationDirective', () => {
+ it('should create an instance', () => {
+ const directive = new CdFormValidationDirective();
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts
new file mode 100644
index 000000000..a88011d35
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts
@@ -0,0 +1,62 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2017 Kevin Kipp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ *
+ * Based on https://github.com/third774/ng-bootstrap-form-validation
+ */
+
+import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
+import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms';
+
+@Directive({
+ // tslint:disable-next-line:directive-selector
+ selector: '[formGroup]'
+})
+export class CdFormValidationDirective {
+ @Input()
+ formGroup: FormGroup;
+ @Output()
+ validSubmit = new EventEmitter<any>();
+
+ @HostListener('submit')
+ onSubmit() {
+ this.markAsTouchedAndDirty(this.formGroup);
+ if (this.formGroup.valid) {
+ this.validSubmit.emit(this.formGroup.value);
+ }
+ }
+
+ markAsTouchedAndDirty(control: AbstractControl) {
+ if (control instanceof FormGroup) {
+ Object.keys(control.controls).forEach((key) =>
+ this.markAsTouchedAndDirty(control.controls[key])
+ );
+ } else if (control instanceof FormArray) {
+ control.controls.forEach((c) => this.markAsTouchedAndDirty(c));
+ } else if (control instanceof FormControl && control.enabled) {
+ control.markAsDirty();
+ control.markAsTouched();
+ control.updateValueAndValidity();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts
new file mode 100644
index 000000000..1fc8f9c7c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts
@@ -0,0 +1,8 @@
+import { PasswordButtonDirective } from './password-button.directive';
+
+describe('PasswordButtonDirective', () => {
+ it('should create an instance', () => {
+ const directive = new PasswordButtonDirective(null, null);
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts
new file mode 100644
index 000000000..d9129858a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts
@@ -0,0 +1,45 @@
+import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@angular/core';
+
+@Directive({
+ selector: '[cdPasswordButton]'
+})
+export class PasswordButtonDirective implements OnInit {
+ private iElement: HTMLElement;
+
+ @Input()
+ private cdPasswordButton: string;
+
+ constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
+
+ ngOnInit() {
+ this.renderer.setAttribute(this.elementRef.nativeElement, 'tabindex', '-1');
+ this.iElement = this.renderer.createElement('i');
+ this.renderer.addClass(this.iElement, 'fa');
+ this.renderer.appendChild(this.elementRef.nativeElement, this.iElement);
+ this.update();
+ }
+
+ private getInputElement() {
+ return document.getElementById(this.cdPasswordButton) as HTMLInputElement;
+ }
+
+ private update() {
+ const inputElement = this.getInputElement();
+ if (inputElement && inputElement.type === 'text') {
+ this.renderer.removeClass(this.iElement, 'fa-eye');
+ this.renderer.addClass(this.iElement, 'fa-eye-slash');
+ } else {
+ this.renderer.removeClass(this.iElement, 'fa-eye-slash');
+ this.renderer.addClass(this.iElement, 'fa-eye');
+ }
+ }
+
+ @HostListener('click')
+ onClick() {
+ const inputElement = this.getInputElement();
+ // Modify the type of the input field.
+ inputElement.type = inputElement.type === 'password' ? 'text' : 'password';
+ // Update the button icon/tooltip.
+ this.update();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts
new file mode 100644
index 000000000..5cebefbc9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts
@@ -0,0 +1,28 @@
+import { NgbConfig, NgbNav, NgbNavChangeEvent, NgbNavConfig } from '@ng-bootstrap/ng-bootstrap';
+
+import { StatefulTabDirective } from './stateful-tab.directive';
+
+describe('StatefulTabDirective', () => {
+ it('should create an instance', () => {
+ const directive = new StatefulTabDirective(null);
+ expect(directive).toBeTruthy();
+ });
+
+ it('should get and select active tab', () => {
+ const nav = new NgbNav('tablist', new NgbNavConfig(new NgbConfig()), <any>null, null);
+ spyOn(nav, 'select');
+ const directive = new StatefulTabDirective(nav);
+ directive.cdStatefulTab = 'bar';
+ window.localStorage.setItem('tabset_bar', 'foo');
+ directive.ngOnInit();
+ expect(nav.select).toHaveBeenCalledWith('foo');
+ });
+
+ it('should store active tab', () => {
+ const directive = new StatefulTabDirective(null);
+ directive.cdStatefulTab = 'bar';
+ const event: NgbNavChangeEvent<string> = { activeId: '', nextId: 'xyz', preventDefault: null };
+ directive.onNavChange(event);
+ expect(window.localStorage.getItem('tabset_bar')).toBe('xyz');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts
new file mode 100644
index 000000000..cf6f27e95
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts
@@ -0,0 +1,31 @@
+import { Directive, Host, HostListener, Input, OnInit, Optional } from '@angular/core';
+
+import { NgbNav, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap';
+
+@Directive({
+ selector: '[cdStatefulTab]'
+})
+export class StatefulTabDirective implements OnInit {
+ @Input()
+ cdStatefulTab: string;
+
+ private localStorage = window.localStorage;
+
+ constructor(@Optional() @Host() private nav: NgbNav) {}
+
+ ngOnInit() {
+ // Is an activate tab identifier stored in the local storage?
+ const activeId = this.localStorage.getItem(`tabset_${this.cdStatefulTab}`);
+ if (activeId) {
+ this.nav.select(activeId);
+ }
+ }
+
+ @HostListener('navChange', ['$event'])
+ onNavChange(event: NgbNavChangeEvent) {
+ // Store the current active tab identifier in the local storage.
+ if (this.cdStatefulTab && event.nextId) {
+ this.localStorage.setItem(`tabset_${this.cdStatefulTab}`, event.nextId);
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts
new file mode 100644
index 000000000..daef6b3c8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts
@@ -0,0 +1,50 @@
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CdFormGroup } from '../forms/cd-form-group';
+import { TrimDirective } from './trim.directive';
+
+@Component({
+ template: `
+ <form [formGroup]="trimForm">
+ <input type="text" formControlName="trimInput" cdTrim />
+ </form>
+ `
+})
+export class TrimComponent {
+ trimForm: CdFormGroup;
+ constructor() {
+ this.trimForm = new CdFormGroup({
+ trimInput: new FormControl()
+ });
+ }
+}
+
+describe('TrimDirective', () => {
+ configureTestBed({
+ imports: [FormsModule, ReactiveFormsModule],
+ declarations: [TrimDirective, TrimComponent]
+ });
+
+ it('should create an instance', () => {
+ const directive = new TrimDirective(null);
+ expect(directive).toBeTruthy();
+ });
+
+ it('should trim', () => {
+ const fixture: ComponentFixture<TrimComponent> = TestBed.createComponent(TrimComponent);
+ const component: TrimComponent = fixture.componentInstance;
+ const inputElement: HTMLInputElement = fixture.debugElement.query(By.css('input'))
+ .nativeElement;
+ fixture.detectChanges();
+
+ inputElement.value = ' a b ';
+ inputElement.dispatchEvent(new Event('input'));
+ const expectedValue = 'a b';
+ expect(inputElement.value).toBe(expectedValue);
+ expect(component.trimForm.getValue('trimInput')).toBe(expectedValue);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts
new file mode 100644
index 000000000..4b3604e43
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts
@@ -0,0 +1,21 @@
+import { Directive, HostListener } from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import _ from 'lodash';
+
+@Directive({
+ selector: '[cdTrim]'
+})
+export class TrimDirective {
+ constructor(private ngControl: NgControl) {}
+
+ @HostListener('input', ['$event.target.value'])
+ onInput(value: string) {
+ this.setValue(value);
+ }
+
+ setValue(value: string): void {
+ value = _.isString(value) ? value.trim() : value;
+ this.ngControl.control.setValue(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts
new file mode 100644
index 000000000..73ce1f239
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts
@@ -0,0 +1,54 @@
+export enum CellTemplate {
+ bold = 'bold',
+ sparkline = 'sparkline',
+ perSecond = 'perSecond',
+ checkIcon = 'checkIcon',
+ routerLink = 'routerLink',
+ // Display the cell with an executing state. The state can be set to the `cdExecuting`
+ // attribute of table rows.
+ // It supports an optional custom configuration:
+ // {
+ // ...
+ // cellTransformation: CellTemplate.executing,
+ // customTemplateConfig: {
+ // valueClass?: string; // Cell value classes.
+ // executingClass?: string; // Executing state classes.
+ // }
+ executing = 'executing',
+ classAdding = 'classAdding',
+ // Display the cell value as a badge. The template
+ // supports an optional custom configuration:
+ // {
+ // ...
+ // cellTransformation: CellTemplate.badge,
+ // customTemplateConfig: {
+ // class?: string; // Additional class name.
+ // prefix?: any; // Prefix of the value to be displayed.
+ // // 'map' and 'prefix' exclude each other.
+ // map?: {
+ // [key: any]: { value: any, class?: string }
+ // }
+ // }
+ // }
+ badge = 'badge',
+ // Maps the value using the given dictionary.
+ // {
+ // ...
+ // cellTransformation: CellTemplate.map,
+ // customTemplateConfig: {
+ // [key: any]: any
+ // }
+ // }
+ map = 'map',
+ // Truncates string if it's longer than the given maximum
+ // string length.
+ // {
+ // ...
+ // cellTransformation: CellTemplate.truncate,
+ // customTemplateConfig: {
+ // length?: number; // Defaults to 30.
+ // omission?: string; // Defaults to empty string.
+ // }
+ // }
+ truncate = 'truncate'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts
new file mode 100644
index 000000000..bf8daafc2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts
@@ -0,0 +1,9 @@
+export enum Components {
+ auth = 'Login',
+ cephfs = 'CephFS',
+ rbd = 'RBD',
+ pool = 'Pool',
+ osd = 'OSD',
+ role = 'Role',
+ user = 'User'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts
new file mode 100644
index 000000000..042394225
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts
@@ -0,0 +1,5 @@
+export enum HealthColor {
+ HEALTH_ERR = 'health-color-error',
+ HEALTH_WARN = 'health-color-warning',
+ HEALTH_OK = 'health-color-healthy'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
new file mode 100644
index 000000000..6b65f04e8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
@@ -0,0 +1,84 @@
+export enum Icons {
+ /* Icons for Symbol */
+ add = 'fa fa-plus', // Create, Add
+ addCircle = 'fa fa-plus-circle', // Plus with Circle
+ minusCircle = 'fa fa-minus-circle', // Minus with Circle
+ edit = 'fa fa-pencil', // Edit, Edit Mode, Rename
+ destroy = 'fa fa-times', // Destroy, Remove, Delete
+ destroyCircle = 'fa fa-times-circle', // Destroy, Remove, Delete
+ exchange = 'fa fa-exchange', // Edit-Peer
+ copy = 'fa fa-copy', // Copy
+ clipboard = 'fa fa-clipboard', // Clipboard
+ flatten = 'fa fa-chain-broken', // Flatten, Link broken, Mark Lost
+ trash = 'fa fa-trash-o', // Move to trash
+ lock = 'fa fa-lock', // Protect
+ unlock = 'fa fa-unlock', // Unprotect
+ clone = 'fa fa-clone', // clone
+ undo = 'fa fa-undo', // Rollback, Restore
+ search = 'fa fa-search', // Search
+ start = 'fa fa-play', // Enable
+ stop = 'fa fa-stop', // Disable
+ analyse = 'fa fa-stethoscope', // Scrub
+ deepCheck = 'fa fa-cog', // Deep Scrub, Setting, Configuration
+ reweight = 'fa fa-balance-scale', // Reweight
+ left = 'fa fa-arrow-left', // Mark out
+ right = 'fa fa-arrow-right', // Mark in
+ down = 'fa fa-arrow-down', // Mark Down
+ erase = 'fa fa-eraser', // Purge color: bd.$white;
+
+ user = 'fa fa-user', // User, Initiators
+ users = 'fa fa-users', // Users, Groups
+ share = 'fa fa-share-alt', // share
+ key = 'fa fa-key-modern', // S3 Keys, Swift Keys, Authentication
+ warning = 'fa fa-exclamation-triangle', // Notification warning
+ info = 'fa fa-info', // Notification information
+ infoCircle = 'fa fa-info-circle', // Info on landing page
+ questionCircle = 'fa fa-question-circle-o',
+ check = 'fa fa-check', // Notification check
+ show = 'fa fa-eye', // Show
+ paragraph = 'fa fa-paragraph', // Silence Matcher - Attribute name
+ terminal = 'fa fa-terminal', // Silence Matcher - Value
+ magic = 'fa fa-magic', // Silence Matcher - Regex checkbox
+ hourglass = 'fa fa-hourglass-o', // Task
+ filledHourglass = 'fa fa-hourglass', // Task
+ table = 'fa fa-table', // Table,
+ spinner = 'fa fa-spinner', // spinner, Load
+ refresh = 'fa fa-refresh', // Refresh
+ bullseye = 'fa fa-bullseye', // Target
+ disk = 'fa fa-hdd-o', // Hard disk, disks
+ server = 'fa fa-server', // Server, Portal
+ filter = 'fa fa-filter', // Filter
+ lineChart = 'fa fa-line-chart', // Line chart
+ signOut = 'fa fa-sign-out', // Sign Out
+ health = 'fa fa-heartbeat', // Health
+ circle = 'fa fa-circle', // Circle
+ bell = 'fa fa-bell', // Notification
+ tag = 'fa fa-tag', // Tag, Badge
+ leftArrow = 'fa fa-angle-left', // Left facing angle
+ rightArrow = 'fa fa-angle-right', // Right facing angle
+ leftArrowDouble = 'fa fa-angle-double-left', // Left facing Double angle
+ rightArrowDouble = 'fa fa-angle-double-right', // Left facing Double angle
+ flag = 'fa fa-flag', // OSD configuration
+ clearFilters = 'fa fa-window-close', // Clear filters, solid x
+ download = 'fa fa-download', // Download
+ upload = 'fa fa-upload', // Upload
+ close = 'fa fa-times', // Close
+ json = 'fa fa-file-code-o', // JSON file
+ text = 'fa fa-file-text', // Text file
+ wrench = 'fa fa-wrench', // Configuration Error
+ enter = 'fa fa-sign-in', // Enter
+ exit = 'fa fa-sign-out', // Exit
+ restart = 'fa fa-history', // Restart
+ deploy = 'fa fa-cube', // Deploy, Redeploy
+
+ /* Icons for special effect */
+ large = 'fa fa-lg', // icon becomes 33% larger
+ large2x = 'fa fa-2x', // icon becomes 50% larger
+ large3x = 'fa fa-3x', // icon becomes 3 times larger
+ stack = 'fa fa-stack', // To stack multiple icons
+ stack1x = 'fa fa-stack-1x', // To stack regularly sized icon
+ stack2x = 'fa fa-stack-2x', // To stack regularly sized icon
+ pulse = 'fa fa-pulse', // To have spinner rotate with 8 steps
+ spin = 'fa fa-spin', // To get any icon to rotate
+ inverse = 'fa fa-inverse' // To get an alternative icon color
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts
new file mode 100644
index 000000000..c82929fb5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts
@@ -0,0 +1,5 @@
+export enum NotificationType {
+ error,
+ info,
+ success
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts
new file mode 100644
index 000000000..98bcb689f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts
@@ -0,0 +1,4 @@
+// http://www.virtsync.com/c-error-codes-include-errno
+export enum UnixErrno {
+ EEXIST = 17 // File exists
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts
new file mode 100644
index 000000000..169059c44
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts
@@ -0,0 +1,6 @@
+export enum ViewCacheStatus {
+ ValueOk = 0,
+ ValueStale = 1,
+ ValueNone = 2,
+ ValueException = 3
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts
new file mode 100644
index 000000000..188294b82
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts
@@ -0,0 +1,33 @@
+import { Validators } from '@angular/forms';
+
+import { CdFormBuilder } from './cd-form-builder';
+import { CdFormGroup } from './cd-form-group';
+
+describe('cd-form-builder', () => {
+ let service: CdFormBuilder;
+
+ beforeEach(() => {
+ service = new CdFormBuilder();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should create a nested CdFormGroup', () => {
+ const form = service.group({
+ nested: service.group({
+ a: [null],
+ b: ['sth'],
+ c: [2, [Validators.min(3)]]
+ }),
+ d: [{ e: 3 }],
+ f: [true]
+ });
+ expect(form.constructor).toBe(CdFormGroup);
+ expect(form instanceof CdFormGroup).toBeTruthy();
+ expect(form.getValue('b')).toBe('sth');
+ expect(form.getValue('d')).toEqual({ e: 3 });
+ expect(form.get('c').valid).toBeFalsy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts
new file mode 100644
index 000000000..9741b1e63
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts
@@ -0,0 +1,20 @@
+import { Injectable } from '@angular/core';
+import { AbstractControlOptions, FormBuilder } from '@angular/forms';
+
+import { CdFormGroup } from './cd-form-group';
+
+/**
+ * CdFormBuilder extends FormBuilder to create an CdFormGroup based form.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class CdFormBuilder extends FormBuilder {
+ group(
+ controlsConfig: { [key: string]: any },
+ extra: AbstractControlOptions | null = null
+ ): CdFormGroup {
+ const form = super.group(controlsConfig, extra);
+ return new CdFormGroup(form.controls, form.validator, form.asyncValidator);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts
new file mode 100644
index 000000000..240da3af8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts
@@ -0,0 +1,184 @@
+import { AbstractControl, FormControl, FormGroup, NgForm } from '@angular/forms';
+
+import { CdFormGroup } from './cd-form-group';
+
+describe('CdFormGroup', () => {
+ let form: CdFormGroup;
+
+ const createTestForm = (controlName: string, value: any): FormGroup =>
+ new FormGroup({
+ [controlName]: new FormControl(value)
+ });
+
+ describe('test get and getValue in nested forms', () => {
+ let formA: FormGroup;
+ let formB: FormGroup;
+ let formC: FormGroup;
+
+ beforeEach(() => {
+ formA = createTestForm('a', 'a');
+ formB = createTestForm('b', 'b');
+ formC = createTestForm('c', 'c');
+ form = new CdFormGroup({
+ formA: formA,
+ formB: formB,
+ formC: formC
+ });
+ });
+
+ it('should find controls out of every form', () => {
+ expect(form.get('a')).toBe(formA.get('a'));
+ expect(form.get('b')).toBe(formB.get('b'));
+ expect(form.get('c')).toBe(formC.get('c'));
+ });
+
+ it('should throw an error if element could be found', () => {
+ expect(() => form.get('d')).toThrowError(`Control 'd' could not be found!`);
+ expect(() => form.get('sth')).toThrowError(`Control 'sth' could not be found!`);
+ });
+ });
+
+ describe('CdFormGroup tests', () => {
+ let x: CdFormGroup, nested: CdFormGroup, a: FormControl, c: FormGroup;
+
+ beforeEach(() => {
+ a = new FormControl('a');
+ x = new CdFormGroup({
+ a: a
+ });
+ nested = new CdFormGroup({
+ lev1: new CdFormGroup({
+ lev2: new FormControl('lev2')
+ })
+ });
+ c = createTestForm('c', 'c');
+ form = new CdFormGroup({
+ nested: nested,
+ cdform: x,
+ b: new FormControl('b'),
+ formC: c
+ });
+ });
+
+ it('should return single value from "a" control in not nested form "x"', () => {
+ expect(x.get('a')).toBe(a);
+ expect(x.getValue('a')).toBe('a');
+ });
+
+ it('should return control "a" out of form "x" in nested form', () => {
+ expect(form.get('a')).toBe(a);
+ expect(form.getValue('a')).toBe('a');
+ });
+
+ it('should return value "b" that is not nested in nested form', () => {
+ expect(form.getValue('b')).toBe('b');
+ });
+
+ it('return value "c" out of normal form group "c" in nested form', () => {
+ expect(form.getValue('c')).toBe('c');
+ });
+
+ it('should return "lev2" value', () => {
+ expect(form.getValue('lev2')).toBe('lev2');
+ });
+
+ it('should nested throw an error if control could not be found', () => {
+ expect(() => form.get('d')).toThrowError(`Control 'd' could not be found!`);
+ expect(() => form.getValue('sth')).toThrowError(`Control 'sth' could not be found!`);
+ });
+ });
+
+ describe('test different values for getValue', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ form_undefined: createTestForm('undefined', undefined),
+ form_null: createTestForm('null', null),
+ form_emptyObject: createTestForm('emptyObject', {}),
+ form_filledObject: createTestForm('filledObject', { notEmpty: 1 }),
+ form_number0: createTestForm('number0', 0),
+ form_number1: createTestForm('number1', 1),
+ form_emptyString: createTestForm('emptyString', ''),
+ form_someString1: createTestForm('someString1', 's'),
+ form_someString2: createTestForm('someString2', 'sth'),
+ form_floating: createTestForm('floating', 0.1),
+ form_false: createTestForm('false', false),
+ form_true: createTestForm('true', true)
+ });
+ });
+
+ it('returns objects', () => {
+ expect(form.getValue('null')).toBe(null);
+ expect(form.getValue('emptyObject')).toEqual({});
+ expect(form.getValue('filledObject')).toEqual({ notEmpty: 1 });
+ });
+
+ it('returns set numbers', () => {
+ expect(form.getValue('number0')).toBe(0);
+ expect(form.getValue('number1')).toBe(1);
+ expect(form.getValue('floating')).toBe(0.1);
+ });
+
+ it('returns strings', () => {
+ expect(form.getValue('emptyString')).toBe('');
+ expect(form.getValue('someString1')).toBe('s');
+ expect(form.getValue('someString2')).toBe('sth');
+ });
+
+ it('returns booleans', () => {
+ expect(form.getValue('true')).toBe(true);
+ expect(form.getValue('false')).toBe(false);
+ });
+
+ it('returns null if control was set as undefined', () => {
+ expect(form.getValue('undefined')).toBe(null);
+ });
+
+ it('returns a falsy value for null, undefined, false and 0', () => {
+ expect(form.getValue('false')).toBeFalsy();
+ expect(form.getValue('null')).toBeFalsy();
+ expect(form.getValue('number0')).toBeFalsy();
+ });
+ });
+
+ describe('should test showError', () => {
+ let formDir: NgForm;
+ let test: AbstractControl;
+
+ beforeEach(() => {
+ formDir = new NgForm([], []);
+ form = new CdFormGroup({
+ test_form: createTestForm('test', '')
+ });
+ test = form.get('test');
+ test.setErrors({ someError: 'failed' });
+ });
+
+ it('should not show an error if not dirty and not submitted', () => {
+ expect(form.showError('test', formDir)).toBe(false);
+ });
+
+ it('should show an error if dirty', () => {
+ test.markAsDirty();
+ expect(form.showError('test', formDir)).toBe(true);
+ });
+
+ it('should show an error if submitted', () => {
+ expect(form.showError('test', <NgForm>{ submitted: true })).toBe(true);
+ });
+
+ it('should not show an error if no error exits', () => {
+ test.setErrors(null);
+ expect(form.showError('test', <NgForm>{ submitted: true })).toBe(false);
+ test.markAsDirty();
+ expect(form.showError('test', formDir)).toBe(false);
+ });
+
+ it('should not show error if the given error is not there', () => {
+ expect(form.showError('test', <NgForm>{ submitted: true }, 'someOtherError')).toBe(false);
+ });
+
+ it('should show error if the given error is there', () => {
+ expect(form.showError('test', <NgForm>{ submitted: true }, 'someError')).toBe(true);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts
new file mode 100644
index 000000000..9869f398c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts
@@ -0,0 +1,75 @@
+import {
+ AbstractControl,
+ AbstractControlOptions,
+ AsyncValidatorFn,
+ FormGroup,
+ NgForm,
+ ValidatorFn
+} from '@angular/forms';
+
+/**
+ * CdFormGroup extends FormGroup with a few new methods that will help form development.
+ */
+export class CdFormGroup extends FormGroup {
+ constructor(
+ public controls: { [key: string]: AbstractControl },
+ validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null,
+ asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null
+ ) {
+ super(controls, validatorOrOpts, asyncValidator);
+ }
+
+ /**
+ * Get a control out of any control even if its nested in other CdFormGroups or a FormGroup
+ */
+ get(controlName: string): AbstractControl {
+ const control = this._get(controlName);
+ if (!control) {
+ throw new Error(`Control '${controlName}' could not be found!`);
+ }
+ return control;
+ }
+
+ _get(controlName: string): AbstractControl {
+ return (
+ super.get(controlName) ||
+ Object.values(this.controls)
+ .filter((c) => c.get)
+ .map((form) => {
+ if (form instanceof CdFormGroup) {
+ return form._get(controlName);
+ }
+ return form.get(controlName);
+ })
+ .find((c) => Boolean(c))
+ );
+ }
+
+ /**
+ * Get the value of a control
+ */
+ getValue(controlName: string): any {
+ return this.get(controlName).value;
+ }
+
+ /**
+ * Sets a control without triggering a value changes event
+ *
+ * Very useful if a function is called through a value changes event but the value
+ * should be changed within the call.
+ */
+ silentSet(controlName: string, value: any) {
+ this.get(controlName).setValue(value, { emitEvent: false });
+ }
+
+ /**
+ * Indicates errors of the control in templates
+ */
+ showError(controlName: string, form: NgForm, errorName?: string): boolean {
+ const control = this.get(controlName);
+ return (
+ (form.submitted || control.dirty) &&
+ (errorName ? control.hasError(errorName) : control.invalid)
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts
new file mode 100644
index 000000000..445c31faf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts
@@ -0,0 +1,32 @@
+import { CdForm, LoadingStatus } from './cd-form';
+
+describe('CdForm', () => {
+ let form: CdForm;
+
+ beforeEach(() => {
+ form = new CdForm();
+ });
+
+ describe('loading', () => {
+ it('should start in loading state', () => {
+ expect(form.loading).toBe(LoadingStatus.Loading);
+ });
+
+ it('should change to ready when calling loadingReady', () => {
+ form.loadingReady();
+ expect(form.loading).toBe(LoadingStatus.Ready);
+ });
+
+ it('should change to error state calling loadingError', () => {
+ form.loadingError();
+ expect(form.loading).toBe(LoadingStatus.Error);
+ });
+
+ it('should change to loading state calling loadingStart', () => {
+ form.loadingError();
+ expect(form.loading).toBe(LoadingStatus.Error);
+ form.loadingStart();
+ expect(form.loading).toBe(LoadingStatus.Loading);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts
new file mode 100644
index 000000000..6fcb40e7d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts
@@ -0,0 +1,26 @@
+export enum LoadingStatus {
+ Loading,
+ Ready,
+ Error,
+ None
+}
+
+export class CdForm {
+ loading = LoadingStatus.Loading;
+
+ loadingStart() {
+ this.loading = LoadingStatus.Loading;
+ }
+
+ loadingReady() {
+ this.loading = LoadingStatus.Ready;
+ }
+
+ loadingError() {
+ this.loading = LoadingStatus.Error;
+ }
+
+ loadingNone() {
+ this.loading = LoadingStatus.None;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts
new file mode 100644
index 000000000..5cf90fdea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts
@@ -0,0 +1,906 @@
+import { fakeAsync, tick } from '@angular/core/testing';
+import { FormControl, Validators } from '@angular/forms';
+
+import _ from 'lodash';
+import { of as observableOf } from 'rxjs';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { FormHelper } from '~/testing/unit-test-helper';
+
+let mockBucketExists = observableOf(true);
+jest.mock('~/app/shared/api/rgw-bucket.service', () => {
+ return {
+ RgwBucketService: jest.fn().mockImplementation(() => {
+ return {
+ exists: () => mockBucketExists
+ };
+ })
+ };
+});
+
+describe('CdValidators', () => {
+ let formHelper: FormHelper;
+ let form: CdFormGroup;
+
+ const expectValid = (value: any) => formHelper.expectValidChange('x', value);
+ const expectPatternError = (value: any) => formHelper.expectErrorChange('x', value, 'pattern');
+ const updateValidity = (controlName: string) => form.get(controlName).updateValueAndValidity();
+
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl()
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ describe('email', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.email);
+ });
+
+ it('should not error on an empty email address', () => {
+ expectValid('');
+ });
+
+ it('should not error on valid email address', () => {
+ expectValid('dashboard@ceph.com');
+ });
+
+ it('should error on invalid email address', () => {
+ formHelper.expectErrorChange('x', 'xyz', 'email');
+ });
+ });
+
+ describe('ip validator', () => {
+ describe('IPv4', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.ip(4));
+ });
+
+ it('should not error on empty addresses', () => {
+ expectValid('');
+ });
+
+ it('should accept valid address', () => {
+ expectValid('19.117.23.141');
+ });
+
+ it('should error containing whitespace', () => {
+ expectPatternError('155.144.133.122 ');
+ expectPatternError('155. 144.133 .122');
+ expectPatternError(' 155.144.133.122');
+ });
+
+ it('should error containing invalid char', () => {
+ expectPatternError('155.144.eee.122 ');
+ expectPatternError('155.1?.133 .1&2');
+ });
+
+ it('should error containing blocks higher than 255', () => {
+ expectPatternError('155.270.133.122');
+ expectPatternError('155.144.133.290');
+ });
+ });
+
+ describe('IPv4', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.ip(6));
+ });
+
+ it('should not error on empty IPv6 addresses', () => {
+ expectValid('');
+ });
+
+ it('should accept valid IPv6 address', () => {
+ expectValid('c4dc:1475:cb0b:24ed:3c80:468b:70cd:1a95');
+ });
+
+ it('should error on IPv6 address containing too many blocks', () => {
+ formHelper.expectErrorChange(
+ 'x',
+ 'c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95:a3f3',
+ 'pattern'
+ );
+ });
+
+ it('should error on IPv6 address containing more than 4 digits per block', () => {
+ expectPatternError('c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95');
+ });
+
+ it('should error on IPv6 address containing whitespace', () => {
+ expectPatternError('c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95 ');
+ expectPatternError('c4dc:14753 :cb0b:24ed:3c80 :468b:70cd :1a95');
+ expectPatternError(' c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95');
+ });
+
+ it('should error on IPv6 address containing invalid char', () => {
+ expectPatternError('c4dx:14753:cb0b:24ed:3c80:468b:70cd:1a95');
+ expectPatternError('c4da:14753:cb0b:24ed:3$80:468b:70cd:1a95');
+ });
+ });
+
+ it('should accept valid IPv4/6 addresses if not protocol version is given', () => {
+ const x = form.get('x');
+ x.setValidators(CdValidators.ip());
+ expectValid('19.117.23.141');
+ expectValid('c4dc:1475:cb0b:24ed:3c80:468b:70cd:1a95');
+ });
+ });
+
+ describe('uuid validator', () => {
+ const expectUuidError = (value: string) =>
+ formHelper.expectErrorChange('x', value, 'invalidUuid', true);
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.uuid());
+ });
+
+ it('should accept empty value', () => {
+ expectValid('');
+ });
+
+ it('should accept valid version 1 uuid', () => {
+ expectValid('171af0b2-c305-11e8-a355-529269fb1459');
+ });
+
+ it('should accept valid version 4 uuid', () => {
+ expectValid('e33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
+ });
+
+ it('should error on uuid containing too many blocks', () => {
+ expectUuidError('e33bbcb6-fcc3-40b1-ae81-3f81706a35d5-23d3');
+ });
+
+ it('should error on uuid containing too many chars in block', () => {
+ expectUuidError('aae33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
+ });
+
+ it('should error on uuid containing invalid char', () => {
+ expectUuidError('x33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
+ expectUuidError('$33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
+ });
+ });
+
+ describe('number validator', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.number());
+ });
+
+ it('should accept empty value', () => {
+ expectValid('');
+ });
+
+ it('should accept numbers', () => {
+ expectValid(42);
+ expectValid(-42);
+ expectValid('42');
+ });
+
+ it('should error on decimal numbers', () => {
+ expectPatternError(42.3);
+ expectPatternError(-42.3);
+ expectPatternError('42.3');
+ });
+
+ it('should error on chars', () => {
+ expectPatternError('char');
+ expectPatternError('42char');
+ });
+
+ it('should error on whitespaces', () => {
+ expectPatternError('42 ');
+ expectPatternError('4 2');
+ });
+ });
+
+ describe('number validator (without negative values)', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.number(false));
+ });
+
+ it('should accept positive numbers', () => {
+ expectValid(42);
+ expectValid('42');
+ });
+
+ it('should error on negative numbers', () => {
+ expectPatternError(-42);
+ expectPatternError('-42');
+ });
+ });
+
+ describe('decimal number validator', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.decimalNumber());
+ });
+
+ it('should accept empty value', () => {
+ expectValid('');
+ });
+
+ it('should accept numbers and decimal numbers', () => {
+ expectValid(42);
+ expectValid(-42);
+ expectValid(42.3);
+ expectValid(-42.3);
+ expectValid('42');
+ expectValid('42.3');
+ });
+
+ it('should error on chars', () => {
+ expectPatternError('42e');
+ expectPatternError('e42.3');
+ });
+
+ it('should error on whitespaces', () => {
+ expectPatternError('42.3 ');
+ expectPatternError('42 .3');
+ });
+ });
+
+ describe('decimal number validator (without negative values)', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.decimalNumber(false));
+ });
+
+ it('should accept positive numbers and decimals', () => {
+ expectValid(42);
+ expectValid(42.3);
+ expectValid('42');
+ expectValid('42.3');
+ });
+
+ it('should error on negative numbers and decimals', () => {
+ expectPatternError(-42);
+ expectPatternError('-42');
+ expectPatternError(-42.3);
+ expectPatternError('-42.3');
+ });
+ });
+
+ describe('requiredIf', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ a: new FormControl(''),
+ b: new FormControl('xyz'),
+ x: new FormControl(true),
+ y: new FormControl('abc'),
+ z: new FormControl('')
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should not error because all conditions are fulfilled', () => {
+ formHelper.setValue('z', 'zyx');
+ const validatorFn = CdValidators.requiredIf({
+ x: true,
+ y: 'abc'
+ });
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should not error because of unmet prerequisites', () => {
+ // Define prereqs that do not match the current values of the form fields.
+ const validatorFn = CdValidators.requiredIf({
+ x: false,
+ y: 'xyz'
+ });
+ // The validator must succeed because the prereqs do not match, so the
+ // validation of the 'z' control will be skipped.
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should error because of an empty value', () => {
+ // Define prereqs that force the validator to validate the value of
+ // the 'z' control.
+ const validatorFn = CdValidators.requiredIf({
+ x: true,
+ y: 'abc'
+ });
+ // The validator must fail because the value of control 'z' is empty.
+ expect(validatorFn(form.get('z'))).toEqual({ required: true });
+ });
+
+ it('should not error because of unsuccessful condition', () => {
+ formHelper.setValue('z', 'zyx');
+ // Define prereqs that force the validator to validate the value of
+ // the 'z' control.
+ const validatorFn = CdValidators.requiredIf(
+ {
+ x: true,
+ z: 'zyx'
+ },
+ () => false
+ );
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should error because of successful condition', () => {
+ const conditionFn = (value: string) => {
+ return value === 'abc';
+ };
+ // Define prereqs that force the validator to validate the value of
+ // the 'y' control.
+ const validatorFn = CdValidators.requiredIf(
+ {
+ x: true,
+ z: ''
+ },
+ conditionFn
+ );
+ expect(validatorFn(form.get('y'))).toEqual({ required: true });
+ });
+
+ it('should process extended prerequisites (1)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ y: { op: '!empty' }
+ });
+ expect(validatorFn(form.get('z'))).toEqual({ required: true });
+ });
+
+ it('should process extended prerequisites (2)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ y: { op: '!empty' }
+ });
+ expect(validatorFn(form.get('b'))).toBeNull();
+ });
+
+ it('should process extended prerequisites (3)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ y: { op: 'minLength', arg1: 2 }
+ });
+ expect(validatorFn(form.get('z'))).toEqual({ required: true });
+ });
+
+ it('should process extended prerequisites (4)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ z: { op: 'empty' }
+ });
+ expect(validatorFn(form.get('a'))).toEqual({ required: true });
+ });
+
+ it('should process extended prerequisites (5)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ z: { op: 'empty' }
+ });
+ expect(validatorFn(form.get('y'))).toBeNull();
+ });
+
+ it('should process extended prerequisites (6)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ y: { op: 'empty' }
+ });
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should process extended prerequisites (7)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ y: { op: 'minLength', arg1: 4 }
+ });
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should process extended prerequisites (8)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ x: { op: 'equal', arg1: true }
+ });
+ expect(validatorFn(form.get('z'))).toEqual({ required: true });
+ });
+
+ it('should process extended prerequisites (9)', () => {
+ const validatorFn = CdValidators.requiredIf({
+ b: { op: '!equal', arg1: 'abc' }
+ });
+ expect(validatorFn(form.get('z'))).toEqual({ required: true });
+ });
+ });
+
+ describe('custom validation', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl(
+ 3,
+ CdValidators.custom('odd', (x: number) => x % 2 === 1)
+ ),
+ y: new FormControl(
+ 5,
+ CdValidators.custom('not-dividable-by-x', (y: number) => {
+ const x = (form && form.get('x').value) || 1;
+ return y % x !== 0;
+ })
+ )
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should test error and valid condition for odd x', () => {
+ formHelper.expectError('x', 'odd');
+ expectValid(4);
+ });
+
+ it('should test error and valid condition for y if its dividable by x', () => {
+ updateValidity('y');
+ formHelper.expectError('y', 'not-dividable-by-x');
+ formHelper.expectValidChange('y', 6);
+ });
+ });
+
+ describe('validate if condition', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl(3),
+ y: new FormControl(5)
+ });
+ CdValidators.validateIf(form.get('x'), () => ((form && form.get('y').value) || 0) > 10, [
+ CdValidators.custom('min', (x: number) => x < 7),
+ CdValidators.custom('max', (x: number) => x > 12)
+ ]);
+ formHelper = new FormHelper(form);
+ });
+
+ it('should test min error', () => {
+ formHelper.setValue('y', 11);
+ updateValidity('x');
+ formHelper.expectError('x', 'min');
+ });
+
+ it('should test max error', () => {
+ formHelper.setValue('y', 11);
+ formHelper.setValue('x', 13);
+ formHelper.expectError('x', 'max');
+ });
+
+ it('should test valid number with validation', () => {
+ formHelper.setValue('y', 11);
+ formHelper.setValue('x', 12);
+ formHelper.expectValid('x');
+ });
+
+ it('should validate automatically if dependency controls are defined', () => {
+ CdValidators.validateIf(
+ form.get('x'),
+ () => ((form && form.getValue('y')) || 0) > 10,
+ [Validators.min(7), Validators.max(12)],
+ undefined,
+ [form.get('y')]
+ );
+
+ formHelper.expectValid('x');
+ formHelper.setValue('y', 13);
+ formHelper.expectError('x', 'min');
+ });
+
+ it('should always validate the permanentValidators', () => {
+ CdValidators.validateIf(
+ form.get('x'),
+ () => ((form && form.getValue('y')) || 0) > 10,
+ [Validators.min(7), Validators.max(12)],
+ [Validators.required],
+ [form.get('y')]
+ );
+
+ formHelper.expectValid('x');
+ formHelper.setValue('x', '');
+ formHelper.expectError('x', 'required');
+ });
+ });
+
+ describe('match', () => {
+ let y: FormControl;
+
+ beforeEach(() => {
+ y = new FormControl('aaa');
+ form = new CdFormGroup({
+ x: new FormControl('aaa'),
+ y: y
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should error when values are different', () => {
+ formHelper.setValue('y', 'aab');
+ CdValidators.match('x', 'y')(form);
+ formHelper.expectValid('x');
+ formHelper.expectError('y', 'match');
+ });
+
+ it('should not error when values are equal', () => {
+ CdValidators.match('x', 'y')(form);
+ formHelper.expectValid('x');
+ formHelper.expectValid('y');
+ });
+
+ it('should unset error when values are equal', () => {
+ y.setErrors({ match: true });
+ CdValidators.match('x', 'y')(form);
+ formHelper.expectValid('x');
+ formHelper.expectValid('y');
+ });
+
+ it('should keep other existing errors', () => {
+ y.setErrors({ match: true, notUnique: true });
+ CdValidators.match('x', 'y')(form);
+ formHelper.expectValid('x');
+ formHelper.expectError('y', 'notUnique');
+ });
+ });
+
+ describe('unique', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl(
+ '',
+ null,
+ CdValidators.unique((value) => {
+ return observableOf('xyz' === value);
+ })
+ )
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should not error because of empty input', () => {
+ expectValid('');
+ });
+
+ it('should not error because of not existing input', fakeAsync(() => {
+ formHelper.setValue('x', 'abc', true);
+ tick(500);
+ formHelper.expectValid('x');
+ }));
+
+ it('should error because of already existing input', fakeAsync(() => {
+ formHelper.setValue('x', 'xyz', true);
+ tick(500);
+ formHelper.expectError('x', 'notUnique');
+ }));
+ });
+
+ describe('composeIf', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl(true),
+ y: new FormControl('abc'),
+ z: new FormControl('')
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should not error because all conditions are fulfilled', () => {
+ formHelper.setValue('z', 'zyx');
+ const validatorFn = CdValidators.composeIf(
+ {
+ x: true,
+ y: 'abc'
+ },
+ [Validators.required]
+ );
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should not error because of unmet prerequisites', () => {
+ // Define prereqs that do not match the current values of the form fields.
+ const validatorFn = CdValidators.composeIf(
+ {
+ x: false,
+ y: 'xyz'
+ },
+ [Validators.required]
+ );
+ // The validator must succeed because the prereqs do not match, so the
+ // validation of the 'z' control will be skipped.
+ expect(validatorFn(form.get('z'))).toBeNull();
+ });
+
+ it('should error because of an empty value', () => {
+ // Define prereqs that force the validator to validate the value of
+ // the 'z' control.
+ const validatorFn = CdValidators.composeIf(
+ {
+ x: true,
+ y: 'abc'
+ },
+ [Validators.required]
+ );
+ // The validator must fail because the value of control 'z' is empty.
+ expect(validatorFn(form.get('z'))).toEqual({ required: true });
+ });
+ });
+
+ describe('dimmlessBinary validators', () => {
+ const i18nMock = (a: string, b: { value: string }) => a.replace('{{value}}', b.value);
+
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl('2 KiB', [CdValidators.binaryMin(1024), CdValidators.binaryMax(3072)])
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should not raise exception an exception for valid change', () => {
+ formHelper.expectValidChange('x', '2.5 KiB');
+ });
+
+ it('should not raise minimum error', () => {
+ formHelper.expectErrorChange('x', '0.5 KiB', 'binaryMin');
+ expect(form.get('x').getError('binaryMin')(i18nMock)).toBe(
+ 'Size has to be at least 1 KiB or more'
+ );
+ });
+
+ it('should not raise maximum error', () => {
+ formHelper.expectErrorChange('x', '4 KiB', 'binaryMax');
+ expect(form.get('x').getError('binaryMax')(i18nMock)).toBe(
+ 'Size has to be at most 3 KiB or less'
+ );
+ });
+ });
+
+ describe('passwordPolicy', () => {
+ let valid: boolean;
+ let callbackCalled: boolean;
+
+ const fakeUserService = {
+ validatePassword: () => {
+ return observableOf({ valid: valid, credits: 17, valuation: 'foo' });
+ }
+ };
+
+ beforeEach(() => {
+ callbackCalled = false;
+ form = new CdFormGroup({
+ x: new FormControl(
+ '',
+ null,
+ CdValidators.passwordPolicy(
+ fakeUserService,
+ () => 'admin',
+ () => {
+ callbackCalled = true;
+ }
+ )
+ )
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('should not error because of empty input', () => {
+ expectValid('');
+ expect(callbackCalled).toBeTruthy();
+ });
+
+ it('should not error because password matches the policy', fakeAsync(() => {
+ valid = true;
+ formHelper.setValue('x', 'abc', true);
+ tick(500);
+ formHelper.expectValid('x');
+ }));
+
+ it('should error because password does not match the policy', fakeAsync(() => {
+ valid = false;
+ formHelper.setValue('x', 'xyz', true);
+ tick(500);
+ formHelper.expectError('x', 'passwordPolicy');
+ }));
+
+ it('should call the callback function', fakeAsync(() => {
+ formHelper.setValue('x', 'xyz', true);
+ tick(500);
+ expect(callbackCalled).toBeTruthy();
+ }));
+
+ describe('sslCert validator', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.sslCert());
+ });
+
+ it('should not error because of empty input', () => {
+ expectValid('');
+ });
+
+ it('should accept SSL certificate', () => {
+ expectValid(
+ '-----BEGIN CERTIFICATE-----\n' +
+ 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' +
+ '...\n' +
+ '3Ztorm2A5tFB\n' +
+ '-----END CERTIFICATE-----\n' +
+ '\n'
+ );
+ });
+
+ it('should error on invalid SSL certificate (1)', () => {
+ expectPatternError(
+ 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' +
+ '...\n' +
+ '3Ztorm2A5tFB\n' +
+ '-----END CERTIFICATE-----\n' +
+ '\n'
+ );
+ });
+
+ it('should error on invalid SSL certificate (2)', () => {
+ expectPatternError(
+ '-----BEGIN CERTIFICATE-----\n' +
+ 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n'
+ );
+ });
+ });
+
+ describe('sslPrivKey validator', () => {
+ beforeEach(() => {
+ form.get('x').setValidators(CdValidators.sslPrivKey());
+ });
+
+ it('should not error because of empty input', () => {
+ expectValid('');
+ });
+
+ it('should accept SSL private key', () => {
+ expectValid(
+ '-----BEGIN RSA PRIVATE KEY-----\n' +
+ 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' +
+ '...\n' +
+ 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' +
+ '-----END RSA PRIVATE KEY-----\n' +
+ '\n'
+ );
+ });
+
+ it('should error on invalid SSL private key (1)', () => {
+ expectPatternError(
+ 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' +
+ '...\n' +
+ 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' +
+ '-----END RSA PRIVATE KEY-----\n' +
+ '\n'
+ );
+ });
+
+ it('should error on invalid SSL private key (2)', () => {
+ expectPatternError(
+ '-----BEGIN RSA PRIVATE KEY-----\n' +
+ 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' +
+ '...\n' +
+ 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n'
+ );
+ });
+ });
+ });
+ describe('bucket', () => {
+ const testValidator = (name: string, valid: boolean, expectedError?: string) => {
+ formHelper.setValue('x', name, true);
+ tick();
+ if (valid) {
+ formHelper.expectValid('x');
+ } else {
+ formHelper.expectError('x', expectedError);
+ }
+ };
+
+ describe('bucketName', () => {
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl('', null, CdValidators.bucketName())
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('bucket name cannot be empty', fakeAsync(() => {
+ testValidator('', false, 'required');
+ }));
+
+ it('bucket names cannot be formatted as IP address', fakeAsync(() => {
+ const testIPs = ['1.1.1.01', '001.1.1.01', '127.0.0.1'];
+ for (const ip of testIPs) {
+ testValidator(ip, false, 'ipAddress');
+ }
+ }));
+
+ it('bucket name must be >= 3 characters long (1/2)', fakeAsync(() => {
+ testValidator('ab', false, 'shouldBeInRange');
+ }));
+
+ it('bucket name must be >= 3 characters long (2/2)', fakeAsync(() => {
+ testValidator('abc', true);
+ }));
+
+ it('bucket name must be <= than 63 characters long (1/2)', fakeAsync(() => {
+ testValidator(_.repeat('a', 64), false, 'shouldBeInRange');
+ }));
+
+ it('bucket name must be <= than 63 characters long (2/2)', fakeAsync(() => {
+ testValidator(_.repeat('a', 63), true);
+ }));
+
+ it('bucket names must not contain uppercase characters or underscores (1/2)', fakeAsync(() => {
+ testValidator('iAmInvalid', false, 'bucketNameInvalid');
+ }));
+
+ it('bucket names can only contain lowercase letters, numbers, periods and hyphens', fakeAsync(() => {
+ testValidator('bk@2', false, 'bucketNameInvalid');
+ }));
+
+ it('bucket names must not contain uppercase characters or underscores (2/2)', fakeAsync(() => {
+ testValidator('i_am_invalid', false, 'bucketNameInvalid');
+ }));
+
+ it('bucket names must start and end with letters or numbers', fakeAsync(() => {
+ testValidator('abcd-', false, 'lowerCaseOrNumber');
+ }));
+
+ it('bucket labels cannot be empty', fakeAsync(() => {
+ testValidator('bk.', false, 'onlyLowerCaseAndNumbers');
+ }));
+
+ it('bucket names with invalid labels (1/3)', fakeAsync(() => {
+ testValidator('abc.1def.Ghi2', false, 'bucketNameInvalid');
+ }));
+
+ it('bucket names with invalid labels (2/3)', fakeAsync(() => {
+ testValidator('abc.1_xy', false, 'bucketNameInvalid');
+ }));
+
+ it('bucket names with invalid labels (3/3)', fakeAsync(() => {
+ testValidator('abc.*def', false, 'bucketNameInvalid');
+ }));
+
+ it('bucket names must be a series of one or more labels and can contain lowercase letters, numbers, and hyphens (1/3)', fakeAsync(() => {
+ testValidator('xyz.abc', true);
+ }));
+
+ it('bucket names must be a series of one or more labels and can contain lowercase letters, numbers, and hyphens (2/3)', fakeAsync(() => {
+ testValidator('abc.1-def', true);
+ }));
+
+ it('bucket names must be a series of one or more labels and can contain lowercase letters, numbers, and hyphens (3/3)', fakeAsync(() => {
+ testValidator('abc.ghi2', true);
+ }));
+
+ it('bucket names must be unique', fakeAsync(() => {
+ testValidator('bucket-name-is-unique', true);
+ }));
+
+ it('bucket names must not contain spaces', fakeAsync(() => {
+ testValidator('bucket name with spaces', false, 'bucketNameInvalid');
+ }));
+ });
+
+ describe('bucketExistence', () => {
+ const rgwBucketService = new RgwBucketService(undefined, undefined);
+
+ beforeEach(() => {
+ form = new CdFormGroup({
+ x: new FormControl('', null, CdValidators.bucketExistence(false, rgwBucketService))
+ });
+ formHelper = new FormHelper(form);
+ });
+
+ it('bucket name cannot be empty', fakeAsync(() => {
+ testValidator('', false, 'required');
+ }));
+
+ it('bucket name should not exist but it does', fakeAsync(() => {
+ testValidator('testName', false, 'bucketNameNotAllowed');
+ }));
+
+ it('bucket name should not exist and it does not', fakeAsync(() => {
+ mockBucketExists = observableOf(false);
+ testValidator('testName', true);
+ }));
+
+ it('bucket name should exist but it does not', fakeAsync(() => {
+ form.get('x').setAsyncValidators(CdValidators.bucketExistence(true, rgwBucketService));
+ mockBucketExists = observableOf(false);
+ testValidator('testName', false, 'bucketNameNotAllowed');
+ }));
+
+ it('bucket name should exist and it does', fakeAsync(() => {
+ form.get('x').setAsyncValidators(CdValidators.bucketExistence(true, rgwBucketService));
+ mockBucketExists = observableOf(true);
+ testValidator('testName', true);
+ }));
+ });
+ });
+});
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
new file mode 100644
index 000000000..22371a50f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts
@@ -0,0 +1,612 @@
+import {
+ AbstractControl,
+ AsyncValidatorFn,
+ ValidationErrors,
+ ValidatorFn,
+ Validators
+} from '@angular/forms';
+
+import _ from 'lodash';
+import { Observable, of as observableOf, timer as observableTimer } from 'rxjs';
+import { map, switchMapTo, take } from 'rxjs/operators';
+
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+export function isEmptyInputValue(value: any): boolean {
+ return value == null || value.length === 0;
+}
+
+export type existsServiceFn = (value: any) => Observable<boolean>;
+
+export class CdValidators {
+ /**
+ * Validator that performs email validation. In contrast to the Angular
+ * email validator an empty email will not be handled as invalid.
+ */
+ static email(control: AbstractControl): ValidationErrors | null {
+ // Exit immediately if value is empty.
+ if (isEmptyInputValue(control.value)) {
+ return null;
+ }
+ return Validators.email(control);
+ }
+
+ /**
+ * Validator function in order to validate IP addresses.
+ * @param {number} version determines the protocol version. It needs to be set to 4 for IPv4 and
+ * to 6 for IPv6 validation. For any other number (it's also the default case) it will return a
+ * function to validate the input string against IPv4 OR IPv6.
+ * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+ * if the validation check fails, otherwise `null`.
+ */
+ static ip(version: number = 0): ValidatorFn {
+ // prettier-ignore
+ const ipv4Rgx =
+ /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i;
+ const ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i;
+
+ if (version === 4) {
+ return Validators.pattern(ipv4Rgx);
+ } else if (version === 6) {
+ return Validators.pattern(ipv6Rgx);
+ } else {
+ return Validators.pattern(new RegExp(ipv4Rgx.source + '|' + ipv6Rgx.source));
+ }
+ }
+
+ /**
+ * Validator function in order to validate numbers.
+ * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+ * if the validation check fails, otherwise `null`.
+ */
+ static number(allowsNegative: boolean = true): ValidatorFn {
+ if (allowsNegative) {
+ return Validators.pattern(/^-?[0-9]+$/i);
+ } else {
+ return Validators.pattern(/^[0-9]+$/i);
+ }
+ }
+
+ /**
+ * Validator function in order to validate decimal numbers.
+ * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+ * if the validation check fails, otherwise `null`.
+ */
+ static decimalNumber(allowsNegative: boolean = true): ValidatorFn {
+ if (allowsNegative) {
+ return Validators.pattern(/^-?[0-9]+(.[0-9]+)?$/i);
+ } else {
+ return Validators.pattern(/^[0-9]+(.[0-9]+)?$/i);
+ }
+ }
+
+ /**
+ * Validator that performs SSL certificate validation.
+ * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+ * if the validation check fails, otherwise `null`.
+ */
+ static sslCert(): ValidatorFn {
+ return Validators.pattern(
+ /^-----BEGIN CERTIFICATE-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END CERTIFICATE-----[\n\r\f]*$/
+ );
+ }
+
+ /**
+ * Validator that performs SSL private key validation.
+ * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+ * if the validation check fails, otherwise `null`.
+ */
+ static sslPrivKey(): ValidatorFn {
+ return Validators.pattern(
+ /^-----BEGIN RSA PRIVATE KEY-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END RSA PRIVATE KEY-----[\n\r\f]*$/
+ );
+ }
+
+ /**
+ * Validator that performs SSL certificate validation of pem format.
+ * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
+ * if the validation check fails, otherwise `null`.
+ */
+ static pemCert(): ValidatorFn {
+ return Validators.pattern(/^-----BEGIN .+-----$.+^-----END .+-----$/ms);
+ }
+
+ /**
+ * Validator that requires controls to fulfill the specified condition if
+ * the specified prerequisites matches. If the prerequisites are fulfilled,
+ * then the given function is executed and if it succeeds, the 'required'
+ * validation error will be returned, otherwise null.
+ * @param {Object} prerequisites An object containing the prerequisites.
+ * To do additional checks rather than checking for equality you can
+ * use the extended prerequisite syntax:
+ * 'field_name': { 'op': '<OPERATOR>', arg1: '<OPERATOR_ARGUMENT>' }
+ * The following operators are supported:
+ * * empty
+ * * !empty
+ * * equal
+ * * !equal
+ * * minLength
+ * ### Example
+ * ```typescript
+ * {
+ * 'generate_key': true,
+ * 'username': 'Max Mustermann'
+ * }
+ * ```
+ * ### Example - Extended prerequisites
+ * ```typescript
+ * {
+ * 'generate_key': { 'op': 'equal', 'arg1': true },
+ * 'username': { 'op': 'minLength', 'arg1': 5 }
+ * }
+ * ```
+ * Only if all prerequisites are fulfilled, then the validation of the
+ * control will be triggered.
+ * @param {Function | undefined} condition The function to be executed when all
+ * prerequisites are fulfilled. If not set, then the {@link isEmptyInputValue}
+ * function will be used by default. The control's value is used as function
+ * argument. The function must return true to set the validation error.
+ * @return {ValidatorFn} Returns the validator function.
+ */
+ static requiredIf(prerequisites: object, condition?: Function | undefined): ValidatorFn {
+ let isWatched = false;
+
+ return (control: AbstractControl): ValidationErrors | null => {
+ if (!isWatched && control.parent) {
+ Object.keys(prerequisites).forEach((key) => {
+ control.parent.get(key).valueChanges.subscribe(() => {
+ control.updateValueAndValidity({ emitEvent: false });
+ });
+ });
+
+ isWatched = true;
+ }
+
+ // Check if all prerequisites met.
+ if (
+ !Object.keys(prerequisites).every((key) => {
+ if (!control.parent) {
+ return false;
+ }
+ const value = control.parent.get(key).value;
+ const prerequisite = prerequisites[key];
+ if (_.isObjectLike(prerequisite)) {
+ let result = false;
+ switch (prerequisite['op']) {
+ case 'empty':
+ result = _.isEmpty(value);
+ break;
+ case '!empty':
+ result = !_.isEmpty(value);
+ break;
+ case 'equal':
+ result = value === prerequisite['arg1'];
+ break;
+ case '!equal':
+ result = value !== prerequisite['arg1'];
+ break;
+ case 'minLength':
+ if (_.isString(value)) {
+ result = value.length >= prerequisite['arg1'];
+ }
+ break;
+ }
+ return result;
+ }
+ return value === prerequisite;
+ })
+ ) {
+ return null;
+ }
+ const success = _.isFunction(condition)
+ ? condition.call(condition, control.value)
+ : isEmptyInputValue(control.value);
+ return success ? { required: true } : null;
+ };
+ }
+
+ /**
+ * Compose multiple validators into a single function that returns the union of
+ * the individual error maps for the provided control when the given prerequisites
+ * are fulfilled.
+ *
+ * @param {Object} prerequisites An object containing the prerequisites as
+ * key/value pairs.
+ * ### Example
+ * ```typescript
+ * {
+ * 'generate_key': true,
+ * 'username': 'Max Mustermann'
+ * }
+ * ```
+ * @param {ValidatorFn[]} validators List of validators that should be taken
+ * into action when the prerequisites are met.
+ * @return {ValidatorFn} Returns the validator function.
+ */
+ static composeIf(prerequisites: object, validators: ValidatorFn[]): ValidatorFn {
+ let isWatched = false;
+ return (control: AbstractControl): ValidationErrors | null => {
+ if (!isWatched && control.parent) {
+ Object.keys(prerequisites).forEach((key) => {
+ control.parent.get(key).valueChanges.subscribe(() => {
+ control.updateValueAndValidity({ emitEvent: false });
+ });
+ });
+ isWatched = true;
+ }
+ // Check if all prerequisites are met.
+ if (
+ !Object.keys(prerequisites).every((key) => {
+ return control.parent && control.parent.get(key).value === prerequisites[key];
+ })
+ ) {
+ return null;
+ }
+ return Validators.compose(validators)(control);
+ };
+ }
+
+ /**
+ * Custom validation by passing a name for the error and a function as error condition.
+ *
+ * @param {string} error
+ * @param {Function} condition - a truthy return value will trigger the error
+ * @returns {ValidatorFn}
+ */
+ static custom(error: string, condition: Function): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: any } => {
+ const value = condition.call(this, control.value);
+ if (value) {
+ return { [error]: value };
+ }
+ return null;
+ };
+ }
+
+ /**
+ * Validate form control if condition is true with validators.
+ *
+ * @param {AbstractControl} formControl
+ * @param {Function} condition
+ * @param {ValidatorFn[]} conditionalValidators List of validators that should only be tested
+ * when the condition is met
+ * @param {ValidatorFn[]} permanentValidators List of validators that should always be tested
+ * @param {AbstractControl[]} watchControls List of controls that the condition depend on.
+ * Every time one of this controls value is updated, the validation will be triggered
+ */
+ static validateIf(
+ formControl: AbstractControl,
+ condition: Function,
+ conditionalValidators: ValidatorFn[],
+ permanentValidators: ValidatorFn[] = [],
+ watchControls: AbstractControl[] = []
+ ) {
+ conditionalValidators = conditionalValidators.concat(permanentValidators);
+
+ formControl.setValidators((control: AbstractControl): {
+ [key: string]: any;
+ } => {
+ const value = condition.call(this);
+ if (value) {
+ return Validators.compose(conditionalValidators)(control);
+ }
+ if (permanentValidators.length > 0) {
+ return Validators.compose(permanentValidators)(control);
+ }
+ return null;
+ });
+
+ watchControls.forEach((control: AbstractControl) => {
+ control.valueChanges.subscribe(() => {
+ formControl.updateValueAndValidity({ emitEvent: false });
+ });
+ });
+ }
+
+ /**
+ * Validator that requires that both specified controls have the same value.
+ * Error will be added to the `path2` control.
+ * @param {string} path1 A dot-delimited string that define the path to the control.
+ * @param {string} path2 A dot-delimited string that define the path to the control.
+ * @return {ValidatorFn} Returns a validator function that always returns `null`.
+ * If the validation fails an error map with the `match` property will be set
+ * on the `path2` control.
+ */
+ static match(path1: string, path2: string): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: any } => {
+ const ctrl1 = control.get(path1);
+ const ctrl2 = control.get(path2);
+ if (!ctrl1 || !ctrl2) {
+ return null;
+ }
+ if (ctrl1.value !== ctrl2.value) {
+ ctrl2.setErrors({ match: true });
+ } else {
+ const hasError = ctrl2.hasError('match');
+ if (hasError) {
+ // Remove the 'match' error. If no more errors exists, then set
+ // the error value to 'null', otherwise the field is still marked
+ // as invalid.
+ const errors = ctrl2.errors;
+ _.unset(errors, 'match');
+ ctrl2.setErrors(_.isEmpty(_.keys(errors)) ? null : errors);
+ }
+ }
+ return null;
+ };
+ }
+
+ /**
+ * Asynchronous validator that requires the control's value to be unique.
+ * The validation is only executed after the specified delay. Every
+ * keystroke during this delay will restart the timer.
+ * @param serviceFn {existsServiceFn} The service function that is
+ * called to check whether the given value exists. It must return
+ * boolean 'true' if the given value exists, otherwise 'false'.
+ * @param serviceFnThis {any} The object to be used as the 'this' object
+ * when calling the serviceFn function. Defaults to null.
+ * @param {number|Date} dueTime The delay time to wait before the
+ * serviceFn call is executed. This is useful to prevent calls on
+ * every keystroke. Defaults to 500.
+ * @return {AsyncValidatorFn} Returns an asynchronous validator function
+ * that returns an error map with the `notUnique` property if the
+ * validation check succeeds, otherwise `null`.
+ */
+ static unique(
+ serviceFn: existsServiceFn,
+ serviceFnThis: any = null,
+ usernameFn?: Function,
+ uidField = false
+ ): AsyncValidatorFn {
+ let uName: string;
+ return (control: AbstractControl): Observable<ValidationErrors | null> => {
+ // Exit immediately if user has not interacted with the control yet
+ // or the control value is empty.
+ if (control.pristine || isEmptyInputValue(control.value)) {
+ return observableOf(null);
+ }
+ uName = control.value;
+ if (_.isFunction(usernameFn) && usernameFn() !== null && usernameFn() !== '') {
+ if (uidField) {
+ uName = `${control.value}$${usernameFn()}`;
+ } else {
+ uName = `${usernameFn()}$${control.value}`;
+ }
+ }
+
+ return observableTimer().pipe(
+ switchMapTo(serviceFn.call(serviceFnThis, uName)),
+ map((resp: boolean) => {
+ if (!resp) {
+ return null;
+ } else {
+ return { notUnique: true };
+ }
+ }),
+ take(1)
+ );
+ };
+ }
+
+ /**
+ * Validator function for UUIDs.
+ * @param required - Defines if it is mandatory to fill in the UUID
+ * @return Validator function that returns an error object containing `invalidUuid` if the
+ * validation failed, `null` otherwise.
+ */
+ static uuid(required = false): ValidatorFn {
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+ return (control: AbstractControl): { [key: string]: any } | null => {
+ if (control.pristine && control.untouched) {
+ return null;
+ } else if (!required && !control.value) {
+ return null;
+ } else if (uuidRe.test(control.value)) {
+ return null;
+ }
+ return { invalidUuid: 'This is not a valid UUID' };
+ };
+ }
+
+ /**
+ * A simple minimum validator vor cd-binary inputs.
+ *
+ * To use the validation message pass I18n into the function as it cannot
+ * be called in a static one.
+ */
+ static binaryMin(bytes: number): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: () => string } | null => {
+ const formatterService = new FormatterService();
+ const currentBytes = new FormatterService().toBytes(control.value);
+ if (bytes <= currentBytes) {
+ return null;
+ }
+ const value = new DimlessBinaryPipe(formatterService).transform(bytes);
+ return {
+ binaryMin: () => $localize`Size has to be at least ${value} or more`
+ };
+ };
+ }
+
+ /**
+ * A simple maximum validator vor cd-binary inputs.
+ *
+ * To use the validation message pass I18n into the function as it cannot
+ * be called in a static one.
+ */
+ static binaryMax(bytes: number): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: () => string } | null => {
+ const formatterService = new FormatterService();
+ const currentBytes = formatterService.toBytes(control.value);
+ if (bytes >= currentBytes) {
+ return null;
+ }
+ const value = new DimlessBinaryPipe(formatterService).transform(bytes);
+ return {
+ binaryMax: () => $localize`Size has to be at most ${value} or less`
+ };
+ };
+ }
+
+ /**
+ * Asynchronous validator that checks if the password meets the password
+ * policy.
+ * @param userServiceThis The object to be used as the 'this' object
+ * when calling the 'validatePassword' method of the 'UserService'.
+ * @param usernameFn Function to get the username that should be
+ * taken into account.
+ * @param callback Callback function that is called after the validation
+ * has been done.
+ * @return {AsyncValidatorFn} Returns an asynchronous validator function
+ * that returns an error map with the `passwordPolicy` property if the
+ * validation check fails, otherwise `null`.
+ */
+ static passwordPolicy(
+ userServiceThis: any,
+ usernameFn?: Function,
+ callback?: (valid: boolean, credits?: number, valuation?: string) => void
+ ): AsyncValidatorFn {
+ return (control: AbstractControl): Observable<ValidationErrors | null> => {
+ if (control.pristine || control.value === '') {
+ if (_.isFunction(callback)) {
+ callback(true, 0);
+ }
+ return observableOf(null);
+ }
+ let username;
+ if (_.isFunction(usernameFn)) {
+ username = usernameFn();
+ }
+ return observableTimer(500).pipe(
+ switchMapTo(_.invoke(userServiceThis, 'validatePassword', control.value, username)),
+ map((resp: { valid: boolean; credits: number; valuation: string }) => {
+ if (_.isFunction(callback)) {
+ callback(resp.valid, resp.credits, resp.valuation);
+ }
+ if (resp.valid) {
+ return null;
+ } else {
+ return { passwordPolicy: true };
+ }
+ }),
+ take(1)
+ );
+ };
+ }
+
+ /**
+ * Validate the bucket name. In general, bucket names should follow domain
+ * name constraints:
+ * - Bucket names must be unique.
+ * - Bucket names cannot be formatted as IP address.
+ * - Bucket names can be between 3 and 63 characters long.
+ * - Bucket names must not contain uppercase characters or underscores.
+ * - Bucket names must start with a lowercase letter or number.
+ * - Bucket names must be a series of one or more labels. Adjacent
+ * labels are separated by a single period (.). Bucket names can
+ * contain lowercase letters, numbers, and hyphens. Each label must
+ * start and end with a lowercase letter or a number.
+ */
+ static bucketName(): AsyncValidatorFn {
+ return (control: AbstractControl): Observable<ValidationErrors | null> => {
+ if (control.pristine || !control.value) {
+ return observableOf({ required: true });
+ }
+ const constraints = [];
+ let errorName: string;
+ // - Bucket names cannot be formatted as IP address.
+ constraints.push(() => {
+ const ipv4Rgx = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i;
+ const ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i;
+ const name = control.value;
+ let notIP = true;
+ if (ipv4Rgx.test(name) || ipv6Rgx.test(name)) {
+ errorName = 'ipAddress';
+ notIP = false;
+ }
+ return notIP;
+ });
+ // - Bucket names can be between 3 and 63 characters long.
+ constraints.push((name: string) => {
+ if (!_.inRange(name.length, 3, 64)) {
+ errorName = 'shouldBeInRange';
+ return false;
+ }
+ // Bucket names can only contain lowercase letters, numbers, periods and hyphens.
+ if (!/^[0-9a-z.-]+$/.test(control.value)) {
+ errorName = 'bucketNameInvalid';
+ return false;
+ }
+ return true;
+ });
+ // - Bucket names must not contain uppercase characters or underscores.
+ // - Bucket names must start with a lowercase letter or number.
+ // - Bucket names must be a series of one or more labels. Adjacent
+ // labels are separated by a single period (.). Bucket names can
+ // contain lowercase letters, numbers, and hyphens. Each label must
+ // start and end with a lowercase letter or a number.
+ constraints.push((name: string) => {
+ const labels = _.split(name, '.');
+ return _.every(labels, (label) => {
+ // Bucket names must not contain uppercase characters or underscores.
+ if (label !== _.toLower(label) || label.includes('_')) {
+ errorName = 'containsUpperCase';
+ return false;
+ }
+ // Bucket labels can contain lowercase letters, numbers, and hyphens.
+ if (!/^[0-9a-z-]+$/.test(label)) {
+ errorName = 'onlyLowerCaseAndNumbers';
+ return false;
+ }
+ // Each label must start and end with a lowercase letter or a number.
+ return _.every([0, label.length - 1], (index) => {
+ errorName = 'lowerCaseOrNumber';
+ return /[a-z]/.test(label[index]) || _.isInteger(_.parseInt(label[index]));
+ });
+ });
+ });
+ if (!_.every(constraints, (func: Function) => func(control.value))) {
+ return observableOf(
+ (() => {
+ switch (errorName) {
+ case 'onlyLowerCaseAndNumbers':
+ return { onlyLowerCaseAndNumbers: true };
+ case 'shouldBeInRange':
+ return { shouldBeInRange: true };
+ case 'ipAddress':
+ return { ipAddress: true };
+ case 'containsUpperCase':
+ return { containsUpperCase: true };
+ case 'lowerCaseOrNumber':
+ return { lowerCaseOrNumber: true };
+ default:
+ return { bucketNameInvalid: true };
+ }
+ })()
+ );
+ }
+
+ return observableOf(null);
+ };
+ }
+
+ static bucketExistence(
+ requiredExistenceResult: boolean,
+ rgwBucketService: RgwBucketService
+ ): AsyncValidatorFn {
+ return (control: AbstractControl): Observable<ValidationErrors | null> => {
+ if (control.pristine || !control.value) {
+ return observableOf({ required: true });
+ }
+ return rgwBucketService
+ .exists(control.value)
+ .pipe(
+ map((existenceResult: boolean) =>
+ existenceResult === requiredExistenceResult ? null : { bucketNameNotAllowed: true }
+ )
+ );
+ };
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts
new file mode 100644
index 000000000..b7b886295
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts
@@ -0,0 +1,23 @@
+export class AlertmanagerSilenceMatcher {
+ name: string;
+ value: any;
+ isRegex: boolean;
+}
+
+export class AlertmanagerSilenceMatcherMatch {
+ status: string;
+ cssClass: string;
+}
+
+export class AlertmanagerSilence {
+ id?: string;
+ matchers: AlertmanagerSilenceMatcher[];
+ startsAt: string; // DateStr
+ endsAt: string; // DateStr
+ updatedAt?: string; // DateStr
+ createdBy: string;
+ comment: string;
+ status?: {
+ state: 'expired' | 'active' | 'pending';
+ };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts
new file mode 100644
index 000000000..10e799929
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts
@@ -0,0 +1,59 @@
+/*
+The MIT License
+
+Copyright (c) 2017 (null) McNull https://github.com/McNull
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+ */
+
+import { ActivatedRouteSnapshot, Resolve, UrlSegment } from '@angular/router';
+
+import { Observable, of } from 'rxjs';
+
+export class BreadcrumbsResolver implements Resolve<IBreadcrumb[]> {
+ public resolve(
+ route: ActivatedRouteSnapshot
+ ): Observable<IBreadcrumb[]> | Promise<IBreadcrumb[]> | IBreadcrumb[] {
+ const data = route.routeConfig.data;
+ const path = data.path === null ? null : this.getFullPath(route);
+
+ const text =
+ typeof data.breadcrumbs === 'string'
+ ? data.breadcrumbs
+ : data.breadcrumbs.text || data.text || path;
+
+ const crumbs: IBreadcrumb[] = [{ text: text, path: path }];
+
+ return of(crumbs);
+ }
+
+ public getFullPath(route: ActivatedRouteSnapshot): string {
+ const relativePath = (segments: UrlSegment[]) =>
+ segments.reduce((a, v) => (a += '/' + v.path), '');
+ const fullPath = (routes: ActivatedRouteSnapshot[]) =>
+ routes.reduce((a, v) => (a += relativePath(v.url)), '');
+
+ return fullPath(route.pathFromRoot);
+ }
+}
+
+export interface IBreadcrumb {
+ text: string;
+ path: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts
new file mode 100644
index 000000000..e327be59a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts
@@ -0,0 +1,32 @@
+import { ValidatorFn } from '@angular/forms';
+
+export class CdFormModalFieldConfig {
+ // --- Generic field properties ---
+ name: string;
+ // 'binary' will use cdDimlessBinary directive on input element
+ // 'select' will use select element
+ type: 'number' | 'text' | 'binary' | 'select' | 'select-badges';
+ label?: string;
+ required?: boolean;
+ value?: any;
+ errors?: { [errorName: string]: string };
+ validators: ValidatorFn[];
+
+ // --- Specific field properties ---
+ typeConfig?: {
+ [prop: string]: any;
+ // 'select':
+ // ---------
+ // placeholder?: string;
+ // options?: Array<{
+ // text: string;
+ // value: any;
+ // }>;
+ //
+ // 'select-badges':
+ // ----------------
+ // customBadges: boolean;
+ // options: Array<SelectOption>;
+ // messages: SelectMessages;
+ };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts
new file mode 100644
index 000000000..df6e8899b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts
@@ -0,0 +1,95 @@
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotification, CdNotificationConfig } from './cd-notification';
+
+describe('cd-notification classes', () => {
+ const expectObject = (something: object, expected: object) => {
+ Object.keys(expected).forEach((key) => expect(something[key]).toBe(expected[key]));
+ };
+
+ // As these Models have a view methods they need to be tested
+ describe('CdNotificationConfig', () => {
+ it('should create a new config without any parameters', () => {
+ expectObject(new CdNotificationConfig(), {
+ application: 'Ceph',
+ applicationClass: 'ceph-icon',
+ message: undefined,
+ options: undefined,
+ title: undefined,
+ type: 1
+ });
+ });
+
+ it('should create a new config with parameters', () => {
+ expectObject(
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'Some Alert',
+ 'Something failed',
+ undefined,
+ 'Prometheus'
+ ),
+ {
+ application: 'Prometheus',
+ applicationClass: 'prometheus-icon',
+ message: 'Something failed',
+ options: undefined,
+ title: 'Some Alert',
+ type: 0
+ }
+ );
+ });
+ });
+
+ describe('CdNotification', () => {
+ beforeEach(() => {
+ const baseTime = new Date('2022-02-22');
+ spyOn(global, 'Date').and.returnValue(baseTime);
+ });
+
+ it('should create a new config without any parameters', () => {
+ expectObject(new CdNotification(), {
+ application: 'Ceph',
+ applicationClass: 'ceph-icon',
+ iconClass: 'fa fa-info',
+ message: undefined,
+ options: undefined,
+ textClass: 'text-info',
+ timestamp: '2022-02-22T00:00:00.000Z',
+ title: undefined,
+ type: 1
+ });
+ });
+
+ it('should create a new config with parameters', () => {
+ expectObject(
+ new CdNotification(
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'Some Alert',
+ 'Something failed',
+ undefined,
+ 'Prometheus'
+ )
+ ),
+ {
+ application: 'Prometheus',
+ applicationClass: 'prometheus-icon',
+ iconClass: 'fa fa-exclamation-triangle',
+ message: 'Something failed',
+ options: undefined,
+ textClass: 'text-danger',
+ timestamp: '2022-02-22T00:00:00.000Z',
+ title: 'Some Alert',
+ type: 0
+ }
+ );
+ });
+
+ it('should expect the right success classes', () => {
+ expectObject(new CdNotification(new CdNotificationConfig(NotificationType.success)), {
+ iconClass: 'fa fa-check',
+ textClass: 'text-success'
+ });
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts
new file mode 100644
index 000000000..c283c5d80
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts
@@ -0,0 +1,48 @@
+import { IndividualConfig } from 'ngx-toastr';
+
+import { Icons } from '../enum/icons.enum';
+import { NotificationType } from '../enum/notification-type.enum';
+
+export class CdNotificationConfig {
+ applicationClass: string;
+ isFinishedTask = false;
+
+ private classes = {
+ Ceph: 'ceph-icon',
+ Prometheus: 'prometheus-icon'
+ };
+
+ constructor(
+ public type: NotificationType = NotificationType.info,
+ public title?: string,
+ public message?: string, // Use this for additional information only
+ public options?: any | IndividualConfig,
+ public application: string = 'Ceph'
+ ) {
+ this.applicationClass = this.classes[this.application];
+ }
+}
+
+export class CdNotification extends CdNotificationConfig {
+ timestamp: string;
+ textClass: string;
+ iconClass: string;
+ duration: number;
+ borderClass: string;
+
+ private textClasses = ['text-danger', 'text-info', 'text-success'];
+ private iconClasses = [Icons.warning, Icons.info, Icons.check];
+ private borderClasses = ['border-danger', 'border-info', 'border-success'];
+
+ constructor(private config: CdNotificationConfig = new CdNotificationConfig()) {
+ super(config.type, config.title, config.message, config.options, config.application);
+ delete this.config;
+ /* string representation of the Date object so it can be directly compared
+ with the timestamps parsed from localStorage */
+ this.timestamp = new Date().toJSON();
+ this.iconClass = this.iconClasses[this.type];
+ this.textClass = this.textClasses[this.type];
+ this.borderClass = this.borderClasses[this.type];
+ this.isFinishedTask = config.isFinishedTask;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts
new file mode 100644
index 000000000..53b9d14fd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts
@@ -0,0 +1,11 @@
+export class CdPwdExpirationSettings {
+ pwdExpirationSpan = 0;
+ pwdExpirationWarning1: number;
+ pwdExpirationWarning2: number;
+
+ constructor(settings: { [key: string]: any }) {
+ this.pwdExpirationSpan = settings.user_pwd_expiration_span;
+ this.pwdExpirationWarning1 = settings.user_pwd_expiration_warning_1;
+ this.pwdExpirationWarning2 = settings.user_pwd_expiration_warning_2;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts
new file mode 100644
index 000000000..fef570f21
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts
@@ -0,0 +1,23 @@
+export class CdPwdPolicySettings {
+ pwdPolicyEnabled: boolean;
+ pwdPolicyMinLength: number;
+ pwdPolicyCheckLengthEnabled: boolean;
+ pwdPolicyCheckOldpwdEnabled: boolean;
+ pwdPolicyCheckUsernameEnabled: boolean;
+ pwdPolicyCheckExclusionListEnabled: boolean;
+ pwdPolicyCheckRepetitiveCharsEnabled: boolean;
+ pwdPolicyCheckSequentialCharsEnabled: boolean;
+ pwdPolicyCheckComplexityEnabled: boolean;
+
+ constructor(settings: { [key: string]: any }) {
+ this.pwdPolicyEnabled = settings.pwd_policy_enabled;
+ this.pwdPolicyMinLength = settings.pwd_policy_min_length;
+ this.pwdPolicyCheckLengthEnabled = settings.pwd_policy_check_length_enabled;
+ this.pwdPolicyCheckOldpwdEnabled = settings.pwd_policy_check_oldpwd_enabled;
+ this.pwdPolicyCheckUsernameEnabled = settings.pwd_policy_check_username_enabled;
+ this.pwdPolicyCheckExclusionListEnabled = settings.pwd_policy_check_exclusion_list_enabled;
+ this.pwdPolicyCheckRepetitiveCharsEnabled = settings.pwd_policy_check_repetitive_chars_enabled;
+ this.pwdPolicyCheckSequentialCharsEnabled = settings.pwd_policy_check_sequential_chars_enabled;
+ this.pwdPolicyCheckComplexityEnabled = settings.pwd_policy_check_complexity_enabled;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts
new file mode 100644
index 000000000..70f06e506
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts
@@ -0,0 +1,44 @@
+import { CdTableSelection } from './cd-table-selection';
+
+export class CdTableAction {
+ // It's possible to assign a string
+ // or a function that returns the link if it has to be dynamic
+ // or none if it's not needed
+ routerLink?: string | Function;
+
+ preserveFragment? = false;
+
+ // This is the function that will be triggered on a click event if defined
+ click?: Function;
+
+ permission: 'create' | 'update' | 'delete' | 'read';
+
+ // The name of the action
+ name: string;
+
+ // The font awesome icon that will be used
+ icon: string;
+
+ /**
+ * You can define the condition to disable the action.
+ * By default all 'update' and 'delete' actions will only be enabled
+ * if one selection is made and no task is running on the selected item.`
+ *
+ * In some cases you might want to give the user a hint why a button is
+ * disabled. This is achieved by returning a string.
+ * */
+ disable?: (_: CdTableSelection) => boolean | string;
+
+ /**
+ * Defines if the button can become 'primary' (displayed as button and not
+ * 'hidden' in the menu). Only one button can be primary at a time. By
+ * default all 'create' actions can be the action button if no or multiple
+ * items are selected. Also, all 'update' and 'delete' actions can be the
+ * action button by default, provided only one item is selected.
+ */
+ canBePrimary?: (_: CdTableSelection) => boolean;
+
+ // In some rare cases you want to hide a action that can be used by the user for example
+ // if one action can lock the item and another action unlocks it
+ visible?: (_: CdTableSelection) => boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts
new file mode 100644
index 000000000..ccdbe82fc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts
@@ -0,0 +1,7 @@
+import { CdTableColumn } from './cd-table-column';
+
+export interface CdTableColumnFilter {
+ column: CdTableColumn;
+ options: { raw: string; formatted: string }[]; // possible options of a filter
+ value?: { raw: string; formatted: string }; // selected option
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts
new file mode 100644
index 000000000..17601f0ad
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts
@@ -0,0 +1,22 @@
+import { TableColumnProp } from '@swimlane/ngx-datatable';
+
+export interface CdTableColumnFiltersChange {
+ /**
+ * Applied filters.
+ */
+ filters: {
+ name: string;
+ prop: TableColumnProp;
+ value: { raw: string; formatted: string };
+ }[];
+
+ /**
+ * Filtered data.
+ */
+ data: any[];
+
+ /**
+ * Filtered out data.
+ */
+ dataOut: any[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts
new file mode 100644
index 000000000..4ed5fdd58
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts
@@ -0,0 +1,38 @@
+import { TableColumn, TableColumnProp } from '@swimlane/ngx-datatable';
+
+import { CellTemplate } from '../enum/cell-template.enum';
+
+export interface CdTableColumn extends TableColumn {
+ cellTransformation?: CellTemplate;
+ isHidden?: boolean;
+ prop: TableColumnProp; // Enforces properties to get sortable columns
+ customTemplateConfig?: any; // Custom configuration used by cell templates.
+
+ /**
+ * Add a filter for the column if true.
+ *
+ * By default, options for the filter are deduced from values of the column.
+ */
+ filterable?: boolean;
+
+ /**
+ * Use these options for filter rather than deducing from values of the column.
+ *
+ * If there is a pipe function associated with the column, pipe function is applied
+ * to the options before displaying them.
+ */
+ filterOptions?: any[];
+
+ /**
+ * Default applied option, should be value in filterOptions.
+ */
+ filterInitValue?: any;
+
+ /**
+ * Specify a custom function for filtering.
+ *
+ * By default, the filter compares if values are string-equal with options. Specify
+ * a customize function if that's not desired. Return true to include a row.
+ */
+ filterPredicate?: (row: any, value: any) => boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts
new file mode 100644
index 000000000..7937d82e6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts
@@ -0,0 +1,44 @@
+import { HttpParams } from '@angular/common/http';
+
+import { PageInfo } from './cd-table-paging';
+
+export class CdTableFetchDataContext {
+ errorConfig = {
+ resetData: true, // Force data table to show no data
+ displayError: true // Show an error panel above the data table
+ };
+
+ /**
+ * The function that should be called from within the error handler
+ * of the 'fetchData' function to display the error panel and to
+ * reset the data table to the correct state.
+ */
+ error: Function;
+ pageInfo: PageInfo = new PageInfo();
+ search = '';
+ sort = '+name';
+
+ constructor(error: () => void) {
+ this.error = error;
+ }
+
+ toParams(): HttpParams {
+ if (this.pageInfo.limit === null) {
+ this.pageInfo.limit = 0;
+ }
+ if (!this.search) {
+ this.search = '';
+ }
+ if (!this.sort || this.sort.length < 2) {
+ this.sort = '+name';
+ }
+ return new HttpParams({
+ fromObject: {
+ offset: String(this.pageInfo.offset * this.pageInfo.limit),
+ limit: String(this.pageInfo.limit),
+ search: this.search,
+ sort: this.sort
+ }
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts
new file mode 100644
index 000000000..3693b527d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts
@@ -0,0 +1,20 @@
+export const PAGE_LIMIT = 10;
+
+export class PageInfo {
+ // Total number of rows in a table
+ count: number;
+
+ // Current page (current row = offset x limit or pageSize)
+ offset = 0;
+
+ // Max. number of rows fetched from the server
+ limit: number = PAGE_LIMIT;
+
+ /*
+ pageSize and limit can be decoupled if hybrid server-side and client-side
+ are used. A use-case would be to reduce the amount of queries: that is,
+ the pageSize (client-side paging) might be 10, but the back-end queries
+ could have a limit of 100. That would avoid triggering requests
+ */
+ pageSize: number = PAGE_LIMIT;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts
new file mode 100644
index 000000000..bbe1e5088
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts
@@ -0,0 +1,45 @@
+export class CdTableSelection {
+ private _selected: any[] = [];
+ hasMultiSelection: boolean;
+ hasSingleSelection: boolean;
+ hasSelection: boolean;
+
+ constructor(rows?: any[]) {
+ if (rows) {
+ this._selected = rows;
+ }
+ this.update();
+ }
+
+ /**
+ * Recalculate the variables based on the current number
+ * of selected rows.
+ */
+ private update() {
+ this.hasSelection = this._selected.length > 0;
+ this.hasSingleSelection = this._selected.length === 1;
+ this.hasMultiSelection = this._selected.length > 1;
+ }
+
+ set selected(selection: any[]) {
+ this._selected = selection;
+ this.update();
+ }
+
+ get selected() {
+ return this._selected;
+ }
+
+ add(row: any) {
+ this._selected.push(row);
+ this.update();
+ }
+
+ /**
+ * Get the first selected row.
+ * @return {any | null}
+ */
+ first() {
+ return this.hasSelection ? this._selected[0] : null;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts
new file mode 100644
index 000000000..edd1af784
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts
@@ -0,0 +1,11 @@
+import { SortPropDir } from '@swimlane/ngx-datatable';
+
+import { CdTableColumn } from './cd-table-column';
+
+export interface CdUserConfig {
+ limit?: number;
+ offset?: number;
+ search?: string;
+ sorts?: SortPropDir[];
+ columns?: CdTableColumn[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts
new file mode 100644
index 000000000..92186aecc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts
@@ -0,0 +1,21 @@
+import { TreeStatus } from '@swimlane/ngx-datatable';
+
+export class CephfsSnapshot {
+ name: string;
+ path: string;
+ created: string;
+}
+
+export class CephfsQuotas {
+ max_bytes?: number;
+ max_files?: number;
+}
+
+export class CephfsDir {
+ name: string;
+ path: string;
+ quotas: CephfsQuotas;
+ snapshots: CephfsSnapshot[];
+ parent: string;
+ treeStatus?: TreeStatus; // Needed for table tree view
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts
new file mode 100644
index 000000000..93a259e79
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts
@@ -0,0 +1,115 @@
+import { ElementRef } from '@angular/core';
+
+export class ChartTooltip {
+ tooltipEl: any;
+ chartEl: any;
+ getStyleLeft: Function;
+ getStyleTop: Function;
+ customColors: Record<string, any> = {
+ backgroundColor: undefined,
+ borderColor: undefined
+ };
+ checkOffset = false;
+
+ /**
+ * Creates an instance of ChartTooltip.
+ * @param {ElementRef} chartCanvas Canvas Element
+ * @param {ElementRef} chartTooltip Tooltip Element
+ * @param {Function} getStyleLeft Function that calculates the value of Left
+ * @param {Function} getStyleTop Function that calculates the value of Top
+ * @memberof ChartTooltip
+ */
+ constructor(
+ chartCanvas: ElementRef,
+ chartTooltip: ElementRef,
+ getStyleLeft: Function,
+ getStyleTop: Function
+ ) {
+ this.chartEl = chartCanvas.nativeElement;
+ this.getStyleLeft = getStyleLeft;
+ this.getStyleTop = getStyleTop;
+ this.tooltipEl = chartTooltip.nativeElement;
+ }
+
+ /**
+ * Implementation of a ChartJS custom tooltip function.
+ *
+ * @param {any} tooltip
+ * @memberof ChartTooltip
+ */
+ customTooltips(tooltip: any) {
+ // Hide if no tooltip
+ if (tooltip.opacity === 0) {
+ this.tooltipEl.style.opacity = 0;
+ return;
+ }
+
+ // Set caret Position
+ this.tooltipEl.classList.remove('above', 'below', 'no-transform');
+ if (tooltip.yAlign) {
+ this.tooltipEl.classList.add(tooltip.yAlign);
+ } else {
+ this.tooltipEl.classList.add('no-transform');
+ }
+
+ // Set Text
+ if (tooltip.body) {
+ const titleLines = tooltip.title || [];
+ const bodyLines = tooltip.body.map((bodyItem: any) => {
+ return bodyItem.lines;
+ });
+
+ let innerHtml = '<thead>';
+
+ titleLines.forEach((title: string) => {
+ innerHtml += '<tr><th>' + this.getTitle(title) + '</th></tr>';
+ });
+ innerHtml += '</thead><tbody>';
+
+ bodyLines.forEach((body: string, i: number) => {
+ const colors = tooltip.labelColors[i];
+ let style = 'background:' + (this.customColors.backgroundColor || colors.backgroundColor);
+ style += '; border-color:' + (this.customColors.borderColor || colors.borderColor);
+ style += '; border-width: 2px';
+ const span = '<span class="chartjs-tooltip-key" style="' + style + '"></span>';
+ innerHtml += '<tr><td nowrap>' + span + this.getBody(body) + '</td></tr>';
+ });
+ innerHtml += '</tbody>';
+
+ const tableRoot = this.tooltipEl.querySelector('table');
+ tableRoot.innerHTML = innerHtml;
+ }
+
+ const positionY = this.chartEl.offsetTop;
+ const positionX = this.chartEl.offsetLeft;
+
+ // Display, position, and set styles for font
+ if (this.checkOffset) {
+ const halfWidth = tooltip.width / 2;
+ this.tooltipEl.classList.remove('transform-left');
+ this.tooltipEl.classList.remove('transform-right');
+ if (tooltip.caretX - halfWidth < 0) {
+ this.tooltipEl.classList.add('transform-left');
+ } else if (tooltip.caretX + halfWidth > this.chartEl.width) {
+ this.tooltipEl.classList.add('transform-right');
+ }
+ }
+
+ this.tooltipEl.style.left = this.getStyleLeft(tooltip, positionX);
+ this.tooltipEl.style.top = this.getStyleTop(tooltip, positionY);
+
+ this.tooltipEl.style.opacity = 1;
+ this.tooltipEl.style.fontFamily = tooltip._fontFamily;
+ this.tooltipEl.style.fontSize = tooltip.fontSize;
+ this.tooltipEl.style.fontStyle = tooltip._fontStyle;
+ this.tooltipEl.style.padding = tooltip.yPadding + 'px ' + tooltip.xPadding + 'px';
+ }
+
+ getBody(body: string) {
+ return body;
+ }
+
+ getTitle(title: string) {
+ return title;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts
new file mode 100644
index 000000000..0a8e403d7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts
@@ -0,0 +1,43 @@
+export enum RbdConfigurationSourceField {
+ global = 0,
+ pool = 1,
+ image = 2
+}
+
+export enum RbdConfigurationType {
+ bps,
+ iops,
+ milliseconds
+}
+
+/**
+ * This configuration can also be set on a pool level.
+ */
+export interface RbdConfigurationEntry {
+ name: string;
+ source: RbdConfigurationSourceField;
+ value: any;
+ type?: RbdConfigurationType; // Non-external field.
+ description?: string; // Non-external field.
+ displayName?: string; // Non-external field. Nice name for the UI which is added in the UI.
+}
+
+/**
+ * This object contains additional information injected into the elements retrieved by the service.
+ */
+export interface RbdConfigurationExtraField {
+ name: string;
+ displayName: string;
+ description: string;
+ type: RbdConfigurationType;
+ readOnly?: boolean;
+}
+
+/**
+ * Represents a set of data to be used for editing or creating configuration options
+ */
+export interface RbdConfigurationSection {
+ heading: string;
+ class: string;
+ options: RbdConfigurationExtraField[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts
new file mode 100644
index 000000000..2c2b7d76e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts
@@ -0,0 +1,4 @@
+export class Credentials {
+ username: string;
+ password: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts
new file mode 100644
index 000000000..a8c8288b6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts
@@ -0,0 +1,17 @@
+export class CrushNode {
+ id: number;
+ name: string;
+ type: string;
+ type_id: number;
+ // For nodes with leafs (Buckets)
+ children?: number[]; // Holds node id's of children
+ // For non root nodes
+ pool_weights?: object;
+ // For leafs (Devices)
+ device_class?: string;
+ crush_weight?: number;
+ exists?: number;
+ primary_affinity?: number;
+ reweight?: number;
+ status?: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts
new file mode 100644
index 000000000..83c1db6b6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts
@@ -0,0 +1,18 @@
+import { CrushStep } from './crush-step';
+
+export class CrushRule {
+ max_size: number;
+ usable_size?: number;
+ min_size: number;
+ rule_id: number;
+ rule_name: string;
+ ruleset: number;
+ steps: CrushStep[];
+}
+
+export class CrushRuleConfig {
+ root: string; // The name of the node under which data should be placed.
+ name: string;
+ failure_domain: string; // The type of CRUSH nodes across which we should separate replicas.
+ device_class?: string; // The device class data should be placed on.
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts
new file mode 100644
index 000000000..3c46a7cd6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts
@@ -0,0 +1,7 @@
+export class CrushStep {
+ op: string;
+ item_name?: string;
+ item?: number;
+ type?: string;
+ num?: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts
new file mode 100644
index 000000000..c69a27851
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts
@@ -0,0 +1,12 @@
+export interface Daemon {
+ nodename: string;
+ container_id: string;
+ container_image_id: string;
+ container_image_name: string;
+ daemon_id: string;
+ daemon_type: string;
+ version: string;
+ status: number;
+ status_desc: string;
+ last_refresh: Date;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts
new file mode 100644
index 000000000..69ab3f5f3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts
@@ -0,0 +1,25 @@
+/**
+ * Fields returned by the back-end.
+ */
+export interface CephDevice {
+ devid: string;
+ location: { host: string; dev: string }[];
+ daemons: string[];
+ life_expectancy_min?: string;
+ life_expectancy_max?: string;
+ life_expectancy_stamp?: string;
+ life_expectancy_enabled?: boolean;
+}
+
+/**
+ * Fields added by the front-end. Fields may be empty if no expectancy is provided for the
+ * CephDevice interface.
+ */
+export interface CdDevice extends CephDevice {
+ life_expectancy_weeks?: {
+ max: number;
+ min: number;
+ };
+ state?: 'good' | 'warning' | 'bad' | 'stale' | 'unknown';
+ readableDaemons?: string; // Human readable daemons (which can wrap lines inside the table cell)
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts
new file mode 100644
index 000000000..ea9985ccd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts
@@ -0,0 +1,17 @@
+export class ErasureCodeProfile {
+ name: string;
+ plugin: string;
+ k?: number;
+ m?: number;
+ c?: number;
+ l?: number;
+ d?: number;
+ packetsize?: number;
+ technique?: string;
+ scalar_mds?: 'jerasure' | 'isa' | 'shec';
+ 'crush-root'?: string;
+ 'crush-locality'?: string;
+ 'crush-failure-domain'?: string;
+ 'crush-device-class'?: string;
+ 'directory'?: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts
new file mode 100644
index 000000000..27dc5968e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts
@@ -0,0 +1,6 @@
+import { Task } from './task';
+
+export class ExecutingTask extends Task {
+ begin_time: number;
+ progress: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts
new file mode 100644
index 000000000..9e7dd5f98
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts
@@ -0,0 +1,15 @@
+import { Task } from './task';
+import { TaskException } from './task-exception';
+
+export class FinishedTask extends Task {
+ begin_time: string;
+ end_time: string;
+ exception: TaskException;
+ latency: number;
+ progress: number;
+ ret_value: any;
+ success: boolean;
+ duration: number;
+
+ errorMessage: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts
new file mode 100644
index 000000000..075decbf7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts
@@ -0,0 +1,8 @@
+export class Flag {
+ code: 'noout' | 'noin' | 'nodown' | 'noup';
+ name: string;
+ description: string;
+ value: boolean;
+ clusterWide: boolean;
+ indeterminate: boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts
new file mode 100644
index 000000000..8b56b291c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts
@@ -0,0 +1,25 @@
+export class ImageSpec {
+ static fromString(imageSpec: string) {
+ const imageSpecSplited = imageSpec.split('/');
+
+ const poolName = imageSpecSplited[0];
+ const namespace = imageSpecSplited.length >= 3 ? imageSpecSplited[1] : null;
+ const imageName = imageSpecSplited.length >= 3 ? imageSpecSplited[2] : imageSpecSplited[1];
+
+ return new this(poolName, namespace, imageName);
+ }
+
+ constructor(public poolName: string, public namespace: string, public imageName: string) {}
+
+ private getNameSpace() {
+ return this.namespace ? `${this.namespace}/` : '';
+ }
+
+ toString() {
+ return `${this.poolName}/${this.getNameSpace()}${this.imageName}`;
+ }
+
+ toStringEncoded() {
+ return encodeURIComponent(`${this.poolName}/${this.getNameSpace()}${this.imageName}`);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts
new file mode 100644
index 000000000..2155c2d87
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts
@@ -0,0 +1,9 @@
+import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+
+export interface InventoryDeviceType {
+ type: string;
+ capacity: number;
+ devices: InventoryDevice[];
+ canSelect: boolean;
+ totalDevices: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts
new file mode 100644
index 000000000..12b4b8348
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts
@@ -0,0 +1,7 @@
+export class LoginResponse {
+ username: string;
+ permissions: object;
+ pwdExpirationDate: number;
+ sso: boolean;
+ pwdUpdateRequired: boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts
new file mode 100644
index 000000000..5487fab0a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts
@@ -0,0 +1,5 @@
+export interface MirroringSummary {
+ content_data?: any;
+ site_name?: any;
+ status?: any;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts
new file mode 100644
index 000000000..22101caaa
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts
@@ -0,0 +1,25 @@
+export enum OrchestratorFeature {
+ HOST_LIST = 'get_hosts',
+ HOST_ADD = 'add_host',
+ HOST_REMOVE = 'remove_host',
+ HOST_LABEL_ADD = 'add_host_label',
+ HOST_LABEL_REMOVE = 'remove_host_label',
+ HOST_MAINTENANCE_ENTER = 'enter_host_maintenance',
+ HOST_MAINTENANCE_EXIT = 'exit_host_maintenance',
+ HOST_FACTS = 'get_facts',
+ HOST_DRAIN = 'drain_host',
+
+ SERVICE_LIST = 'describe_service',
+ SERVICE_CREATE = 'apply',
+ SERVICE_EDIT = 'apply',
+ SERVICE_DELETE = 'remove_service',
+ SERVICE_RELOAD = 'service_action',
+ DAEMON_LIST = 'list_daemons',
+
+ OSD_GET_REMOVE_STATUS = 'remove_osds_status',
+ OSD_CREATE = 'apply_drivegroups',
+ OSD_DELETE = 'remove_osds',
+
+ DEVICE_LIST = 'get_inventory',
+ DEVICE_BLINK_LIGHT = 'blink_device_light'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts
new file mode 100644
index 000000000..4eceba8c0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts
@@ -0,0 +1,9 @@
+export interface OrchestratorStatus {
+ available: boolean;
+ message: string;
+ features: {
+ [feature: string]: {
+ available: boolean;
+ };
+ };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts
new file mode 100644
index 000000000..cae869efe
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts
@@ -0,0 +1,24 @@
+export enum OsdDeploymentOptions {
+ COST_CAPACITY = 'cost_capacity',
+ THROUGHPUT = 'throughput_optimized',
+ IOPS = 'iops_optimized'
+}
+
+export interface DeploymentOption {
+ name: OsdDeploymentOptions;
+ title: string;
+ desc: string;
+ capacity: number;
+ available: boolean;
+ hdd_used: number;
+ used: number;
+ nvme_used: number;
+ ssd_used: number;
+}
+
+export interface DeploymentOptions {
+ options: {
+ [key in OsdDeploymentOptions]: DeploymentOption;
+ };
+ recommended_option: OsdDeploymentOptions;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts
new file mode 100644
index 000000000..b7bc10fc0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts
@@ -0,0 +1,4 @@
+export class OsdSettings {
+ nearfull_ratio: number;
+ full_ratio: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts
new file mode 100644
index 000000000..fb2c90469
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts
@@ -0,0 +1,62 @@
+import { Permissions } from './permissions';
+
+describe('cd-notification classes', () => {
+ it('should show empty permissions', () => {
+ expect(new Permissions({})).toEqual({
+ cephfs: { create: false, delete: false, read: false, update: false },
+ configOpt: { create: false, delete: false, read: false, update: false },
+ grafana: { create: false, delete: false, read: false, update: false },
+ hosts: { create: false, delete: false, read: false, update: false },
+ iscsi: { create: false, delete: false, read: false, update: false },
+ log: { create: false, delete: false, read: false, update: false },
+ manager: { create: false, delete: false, read: false, update: false },
+ monitor: { create: false, delete: false, read: false, update: false },
+ nfs: { create: false, delete: false, read: false, update: false },
+ osd: { create: false, delete: false, read: false, update: false },
+ pool: { create: false, delete: false, read: false, update: false },
+ prometheus: { create: false, delete: false, read: false, update: false },
+ rbdImage: { create: false, delete: false, read: false, update: false },
+ rbdMirroring: { create: false, delete: false, read: false, update: false },
+ rgw: { create: false, delete: false, read: false, update: false },
+ user: { create: false, delete: false, read: false, update: false }
+ });
+ });
+
+ it('should show full permissions', () => {
+ const fullyGranted = {
+ cephfs: ['create', 'read', 'update', 'delete'],
+ 'config-opt': ['create', 'read', 'update', 'delete'],
+ grafana: ['create', 'read', 'update', 'delete'],
+ hosts: ['create', 'read', 'update', 'delete'],
+ iscsi: ['create', 'read', 'update', 'delete'],
+ log: ['create', 'read', 'update', 'delete'],
+ manager: ['create', 'read', 'update', 'delete'],
+ monitor: ['create', 'read', 'update', 'delete'],
+ osd: ['create', 'read', 'update', 'delete'],
+ pool: ['create', 'read', 'update', 'delete'],
+ prometheus: ['create', 'read', 'update', 'delete'],
+ 'rbd-image': ['create', 'read', 'update', 'delete'],
+ 'rbd-mirroring': ['create', 'read', 'update', 'delete'],
+ rgw: ['create', 'read', 'update', 'delete'],
+ user: ['create', 'read', 'update', 'delete']
+ };
+ expect(new Permissions(fullyGranted)).toEqual({
+ cephfs: { create: true, delete: true, read: true, update: true },
+ configOpt: { create: true, delete: true, read: true, update: true },
+ grafana: { create: true, delete: true, read: true, update: true },
+ hosts: { create: true, delete: true, read: true, update: true },
+ iscsi: { create: true, delete: true, read: true, update: true },
+ log: { create: true, delete: true, read: true, update: true },
+ manager: { create: true, delete: true, read: true, update: true },
+ monitor: { create: true, delete: true, read: true, update: true },
+ nfs: { create: false, delete: false, read: false, update: false },
+ osd: { create: true, delete: true, read: true, update: true },
+ pool: { create: true, delete: true, read: true, update: true },
+ prometheus: { create: true, delete: true, read: true, update: true },
+ rbdImage: { create: true, delete: true, read: true, update: true },
+ rbdMirroring: { create: true, delete: true, read: true, update: true },
+ rgw: { create: true, delete: true, read: true, update: true },
+ user: { create: true, delete: true, read: true, update: true }
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts
new file mode 100644
index 000000000..3f2c87ed1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts
@@ -0,0 +1,50 @@
+export class Permission {
+ read: boolean;
+ create: boolean;
+ update: boolean;
+ delete: boolean;
+
+ constructor(serverPermission: Array<string> = []) {
+ ['read', 'create', 'update', 'delete'].forEach(
+ (permission) => (this[permission] = serverPermission.includes(permission))
+ );
+ }
+}
+
+export class Permissions {
+ hosts: Permission;
+ configOpt: Permission;
+ pool: Permission;
+ osd: Permission;
+ monitor: Permission;
+ rbdImage: Permission;
+ iscsi: Permission;
+ rbdMirroring: Permission;
+ rgw: Permission;
+ cephfs: Permission;
+ manager: Permission;
+ log: Permission;
+ user: Permission;
+ grafana: Permission;
+ prometheus: Permission;
+ nfs: Permission;
+
+ constructor(serverPermissions: any) {
+ this.hosts = new Permission(serverPermissions['hosts']);
+ this.configOpt = new Permission(serverPermissions['config-opt']);
+ this.pool = new Permission(serverPermissions['pool']);
+ this.osd = new Permission(serverPermissions['osd']);
+ this.monitor = new Permission(serverPermissions['monitor']);
+ this.rbdImage = new Permission(serverPermissions['rbd-image']);
+ this.iscsi = new Permission(serverPermissions['iscsi']);
+ this.rbdMirroring = new Permission(serverPermissions['rbd-mirroring']);
+ this.rgw = new Permission(serverPermissions['rgw']);
+ this.cephfs = new Permission(serverPermissions['cephfs']);
+ this.manager = new Permission(serverPermissions['manager']);
+ this.log = new Permission(serverPermissions['log']);
+ this.user = new Permission(serverPermissions['user']);
+ this.grafana = new Permission(serverPermissions['grafana']);
+ this.prometheus = new Permission(serverPermissions['prometheus']);
+ this.nfs = new Permission(serverPermissions['nfs-ganesha']);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts
new file mode 100644
index 000000000..c5cc0bb6d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts
@@ -0,0 +1,20 @@
+import { CrushNode } from './crush-node';
+import { CrushRule } from './crush-rule';
+import { ErasureCodeProfile } from './erasure-code-profile';
+
+export class PoolFormInfo {
+ pool_names: string[];
+ osd_count: number;
+ is_all_bluestore: boolean;
+ bluestore_compression_algorithm: string;
+ compression_algorithms: string[];
+ compression_modes: string[];
+ crush_rules_replicated: CrushRule[];
+ crush_rules_erasure: CrushRule[];
+ pg_autoscale_default_mode: string;
+ pg_autoscale_modes: string[];
+ erasure_code_profiles: ErasureCodeProfile[];
+ used_rules: { [rule_name: string]: string[] };
+ used_profiles: { [profile_name: string]: string[] };
+ nodes: CrushNode[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts
new file mode 100644
index 000000000..1239dcccd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts
@@ -0,0 +1,84 @@
+export class PrometheusAlertLabels {
+ alertname: string;
+ instance: string;
+ job: string;
+ severity: string;
+}
+
+class Annotations {
+ description: string;
+}
+
+class CommonAlertmanagerAlert {
+ labels: PrometheusAlertLabels;
+ annotations: Annotations;
+ startsAt: string; // Date string
+ endsAt: string; // Date string
+ generatorURL: string;
+}
+
+class PrometheusAlert {
+ labels: PrometheusAlertLabels;
+ annotations: Annotations;
+ state: 'pending' | 'firing';
+ activeAt: string; // Date string
+ value: number;
+}
+
+export interface PrometheusRuleGroup {
+ name: string;
+ file: string;
+ rules: PrometheusRule[];
+}
+
+export class PrometheusRule {
+ name: string; // => PrometheusAlertLabels.alertname
+ query: string;
+ duration: 10;
+ labels: {
+ severity: string; // => PrometheusAlertLabels.severity
+ };
+ annotations: Annotations;
+ alerts: PrometheusAlert[]; // Shows only active alerts
+ health: string;
+ type: string;
+ group?: string; // Added field for flattened list
+}
+
+export class AlertmanagerAlert extends CommonAlertmanagerAlert {
+ status: {
+ state: 'unprocessed' | 'active' | 'suppressed';
+ silencedBy: null | string[];
+ inhibitedBy: null | string[];
+ };
+ receivers: string[];
+ fingerprint: string;
+}
+
+export class AlertmanagerNotificationAlert extends CommonAlertmanagerAlert {
+ status: 'firing' | 'resolved';
+}
+
+export class AlertmanagerNotification {
+ status: 'firing' | 'resolved';
+ groupLabels: object;
+ commonAnnotations: object;
+ groupKey: string;
+ notified: string;
+ id: string;
+ alerts: AlertmanagerNotificationAlert[];
+ version: string;
+ receiver: string;
+ externalURL: string;
+ commonLabels: {
+ severity: string;
+ };
+}
+
+export class PrometheusCustomAlert {
+ status: 'resolved' | 'unprocessed' | 'active' | 'suppressed';
+ name: string;
+ url: string;
+ description: string;
+ fingerprint?: string | boolean;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts
new file mode 100644
index 000000000..dd64422e1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts
@@ -0,0 +1,45 @@
+export interface CephServiceStatus {
+ container_image_id: string;
+ container_image_name: string;
+ size: number;
+ running: number;
+ last_refresh: Date;
+ created: Date;
+}
+
+// This will become handy when creating arbitrary services
+export interface CephServiceSpec {
+ service_name: string;
+ service_type: string;
+ service_id: string;
+ unmanaged: boolean;
+ status: CephServiceStatus;
+ spec: CephServiceAdditionalSpec;
+ placement: CephServicePlacement;
+}
+
+export interface CephServiceAdditionalSpec {
+ backend_service: string;
+ api_user: string;
+ api_password: string;
+ api_port: number;
+ api_secure: boolean;
+ rgw_frontend_port: number;
+ trusted_ip_list: string[];
+ virtual_ip: string;
+ frontend_port: number;
+ monitor_port: number;
+ virtual_interface_networks: string[];
+ pool: string;
+ rgw_frontend_ssl_certificate: string;
+ ssl: boolean;
+ ssl_cert: string;
+ ssl_key: string;
+}
+
+export interface CephServicePlacement {
+ count: number;
+ placement: string;
+ hosts: string[];
+ label: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts
new file mode 100644
index 000000000..f553652bc
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts
@@ -0,0 +1,253 @@
+export interface SmartAttribute {
+ flags: {
+ auto_keep: boolean;
+ error_rate: boolean;
+ event_count: boolean;
+ performance: boolean;
+ prefailure: boolean;
+ string: string;
+ updated_online: boolean;
+ value: number;
+ };
+ id: number;
+ name: string;
+ raw: { string: string; value: number };
+ thresh: number;
+ value: number;
+ when_failed: string;
+ worst: number;
+}
+
+/**
+ * The error structure returned from the back-end if SMART data couldn't be
+ * retrieved.
+ */
+export interface SmartError {
+ dev: string;
+ error: string;
+ nvme_smart_health_information_add_log_error: string;
+ nvme_smart_health_information_add_log_error_code: number;
+ nvme_vendor: string;
+ smartctl_error_code: number;
+ smartctl_output: string;
+}
+
+/**
+ * Common smartctl output structure.
+ */
+interface SmartCtlOutput {
+ argv: string[];
+ build_info: string;
+ exit_status: number;
+ output: string[];
+ platform_info: string;
+ svn_revision: string;
+ version: number[];
+}
+
+/**
+ * Common smartctl device structure.
+ */
+interface SmartCtlDevice {
+ info_name: string;
+ name: string;
+ protocol: string;
+ type: string;
+}
+
+/**
+ * smartctl data structure shared among HDD/NVMe.
+ */
+interface SmartCtlBaseDataV1 {
+ device: SmartCtlDevice;
+ firmware_version: string;
+ json_format_version: number[];
+ local_time: { asctime: string; time_t: number };
+ logical_block_size: number;
+ model_name: string;
+ nvme_smart_health_information_add_log_error: string;
+ nvme_smart_health_information_add_log_error_code: number;
+ nvme_vendor: string;
+ power_cycle_count: number;
+ power_on_time: { hours: number };
+ serial_number: string;
+ smart_status: { passed: boolean; nvme?: { value: number } };
+ smartctl: SmartCtlOutput;
+ temperature: { current: number };
+ user_capacity: { blocks: number; bytes: number };
+}
+
+export interface RVWAttributes {
+ correction_algorithm_invocations: number;
+ errors_corrected_by_eccdelayed: number;
+ errors_corrected_by_eccfast: number;
+ errors_corrected_by_rereads_rewrites: number;
+ gigabytes_processed: number;
+ total_errors_corrected: number;
+ total_uncorrected_errors: number;
+}
+
+/**
+ * Result structure of `smartctl` applied on an SCSI. Returned by the back-end.
+ */
+export interface IscsiSmartDataV1 extends SmartCtlBaseDataV1 {
+ scsi_error_counter_log: {
+ read: RVWAttributes[];
+ };
+ scsi_grown_defect_list: number;
+}
+
+/**
+ * Result structure of `smartctl` applied on an HDD. Returned by the back-end.
+ */
+export interface AtaSmartDataV1 extends SmartCtlBaseDataV1 {
+ ata_sct_capabilities: {
+ data_table_supported: boolean;
+ error_recovery_control_supported: boolean;
+ feature_control_supported: boolean;
+ value: number;
+ };
+ ata_smart_attributes: {
+ revision: number;
+ table: SmartAttribute[];
+ };
+ ata_smart_data: {
+ capabilities: {
+ attribute_autosave_enabled: boolean;
+ conveyance_self_test_supported: boolean;
+ error_logging_supported: boolean;
+ exec_offline_immediate_supported: boolean;
+ gp_logging_supported: boolean;
+ offline_is_aborted_upon_new_cmd: boolean;
+ offline_surface_scan_supported: boolean;
+ selective_self_test_supported: boolean;
+ self_tests_supported: boolean;
+ values: number[];
+ };
+ offline_data_collection: {
+ completion_seconds: number;
+ status: { string: string; value: number };
+ };
+ self_test: {
+ polling_minutes: { conveyance: number; extended: number; short: number };
+ status: { passed: boolean; string: string; value: number };
+ };
+ };
+ ata_smart_error_log: { summary: { count: number; revision: number } };
+ ata_smart_selective_self_test_log: {
+ flags: { remainder_scan_enabled: boolean; value: number };
+ power_up_scan_resume_minutes: number;
+ revision: number;
+ table: {
+ lba_max: number;
+ lba_min: number;
+ status: { string: string; value: number };
+ }[];
+ };
+ ata_smart_self_test_log: { standard: { count: number; revision: number } };
+ ata_version: { major_value: number; minor_value: number; string: string };
+ in_smartctl_database: boolean;
+ interface_speed: {
+ current: {
+ bits_per_unit: number;
+ sata_value: number;
+ string: string;
+ units_per_second: number;
+ };
+ max: {
+ bits_per_unit: number;
+ sata_value: number;
+ string: string;
+ units_per_second: number;
+ };
+ };
+ model_family: string;
+ physical_block_size: number;
+ rotation_rate: number;
+ sata_version: { string: string; value: number };
+ smart_status: { passed: boolean };
+ smartctl: SmartCtlOutput;
+ wwn: { id: number; naa: number; oui: number };
+}
+
+/**
+ * Result structure of `smartctl` returned by Ceph and then back-end applied on
+ * an NVMe.
+ */
+export interface NvmeSmartDataV1 extends SmartCtlBaseDataV1 {
+ nvme_controller_id: number;
+ nvme_ieee_oui_identifier: number;
+ nvme_namespaces: {
+ capacity: { blocks: number; bytes: number };
+ eui64: { ext_id: number; oui: number };
+ formatted_lba_size: number;
+ id: number;
+ size: { blocks: number; bytes: number };
+ utilization: { blocks: number; bytes: number };
+ }[];
+ nvme_number_of_namespaces: number;
+ nvme_pci_vendor: { id: number; subsystem_id: number };
+ nvme_smart_health_information_log: {
+ available_spare: number;
+ available_spare_threshold: number;
+ controller_busy_time: number;
+ critical_comp_time: number;
+ critical_warning: number;
+ data_units_read: number;
+ data_units_written: number;
+ host_reads: number;
+ host_writes: number;
+ media_errors: number;
+ num_err_log_entries: number;
+ percentage_used: number;
+ power_cycles: number;
+ power_on_hours: number;
+ temperature: number;
+ temperature_sensors: number[];
+ unsafe_shutdowns: number;
+ warning_temp_time: number;
+ };
+ nvme_total_capacity: number;
+ nvme_unallocated_capacity: number;
+}
+
+/**
+ * The shared fields each result has after it has been processed by the front-end.
+ */
+interface SmartBasicResult {
+ device: string;
+ identifier: string;
+}
+
+/**
+ * The SMART data response structure of the back-end. Per device it will either
+ * contain the structure for a HDD, NVMe or an error.
+ */
+export interface SmartDataResponseV1 {
+ [deviceId: string]: AtaSmartDataV1 | NvmeSmartDataV1 | SmartError;
+}
+
+/**
+ * The SMART data result after it has been processed by the front-end.
+ */
+export interface SmartDataResult extends SmartBasicResult {
+ info: { [key: string]: any };
+ smart: {
+ attributes?: any;
+ data?: any;
+ nvmeData?: any;
+ scsi_error_counter_log?: any;
+ scsi_grown_defect_list?: any;
+ };
+}
+
+/**
+ * The SMART error result after is has been processed by the front-end. If SMART
+ * data couldn't be retrieved, this is the structure which is returned.
+ */
+export interface SmartErrorResult extends SmartBasicResult {
+ error: string;
+ smartctl_error_code: number;
+ smartctl_output: string;
+ userMessage: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts
new file mode 100644
index 000000000..f2854a0eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts
@@ -0,0 +1,15 @@
+import { ExecutingTask } from './executing-task';
+import { FinishedTask } from './finished-task';
+
+export class Summary {
+ executing_tasks?: ExecutingTask[];
+ filesystems?: any[];
+ finished_tasks?: FinishedTask[];
+ have_mon_connection?: boolean;
+ health_status?: string;
+ mgr_host?: string;
+ mgr_id?: string;
+ rbd_mirroring?: any;
+ rbd_pools?: any[];
+ version?: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts
new file mode 100644
index 000000000..ba38e4aab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts
@@ -0,0 +1,9 @@
+import { Task } from './task';
+
+export class TaskException {
+ status: number;
+ code: number;
+ component: string;
+ detail: string;
+ task: Task;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts
new file mode 100644
index 000000000..0adec5a0f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts
@@ -0,0 +1,10 @@
+export class Task {
+ constructor(name?: string, metadata?: object) {
+ this.name = name;
+ this.metadata = metadata;
+ }
+ name: string;
+ metadata: object;
+
+ description: string;
+}
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 000000000..177feb486
--- /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/pipes/array.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.spec.ts
new file mode 100755
index 000000000..610e22c43
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.spec.ts
@@ -0,0 +1,21 @@
+import { ArrayPipe } from './array.pipe';
+
+describe('ArrayPipe', () => {
+ const pipe = new ArrayPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms string to array', () => {
+ expect(pipe.transform('foo')).toStrictEqual(['foo']);
+ });
+
+ it('transforms array to array', () => {
+ expect(pipe.transform(['foo'], true)).toStrictEqual([['foo']]);
+ });
+
+ it('do not transforms array to array', () => {
+ expect(pipe.transform(['foo'])).toStrictEqual(['foo']);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts
new file mode 100755
index 000000000..f82e35316
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts
@@ -0,0 +1,26 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+/**
+ * Convert the given value to an array.
+ */
+@Pipe({
+ name: 'array'
+})
+export class ArrayPipe implements PipeTransform {
+ /**
+ * Convert the given value into an array. If the value is already an
+ * array, then nothing happens, except the `force` flag is set.
+ * @param value The value to process.
+ * @param force Convert the specified value to an array, either it is
+ * already an array.
+ */
+ transform(value: any, force = false): any[] {
+ let result = value;
+ if (!_.isArray(value) || (_.isArray(value) && force)) {
+ result = [value];
+ }
+ return result;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts
new file mode 100644
index 000000000..a0b8019a7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts
@@ -0,0 +1,37 @@
+import { BooleanTextPipe } from './boolean-text.pipe';
+
+describe('BooleanTextPipe', () => {
+ let pipe: BooleanTextPipe;
+
+ beforeEach(() => {
+ pipe = new BooleanTextPipe();
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms true', () => {
+ expect(pipe.transform(true)).toEqual('Yes');
+ });
+
+ it('transforms true, alternative text', () => {
+ expect(pipe.transform(true, 'foo')).toEqual('foo');
+ });
+
+ it('transforms 1', () => {
+ expect(pipe.transform(1)).toEqual('Yes');
+ });
+
+ it('transforms false', () => {
+ expect(pipe.transform(false)).toEqual('No');
+ });
+
+ it('transforms false, alternative text', () => {
+ expect(pipe.transform(false, 'foo', 'bar')).toEqual('bar');
+ });
+
+ it('transforms 0', () => {
+ expect(pipe.transform(0)).toEqual('No');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts
new file mode 100644
index 000000000..70432f9be
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts
@@ -0,0 +1,14 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'booleanText'
+})
+export class BooleanTextPipe implements PipeTransform {
+ transform(
+ value: any,
+ truthyText: string = $localize`Yes`,
+ falsyText: string = $localize`No`
+ ): string {
+ return Boolean(value) ? truthyText : falsyText;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts
new file mode 100755
index 000000000..36c5ed021
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts
@@ -0,0 +1,57 @@
+import { BooleanPipe } from './boolean.pipe';
+
+describe('BooleanPipe', () => {
+ const pipe = new BooleanPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms to false [1/4]', () => {
+ expect(pipe.transform('n')).toBe(false);
+ });
+
+ it('transforms to false [2/4]', () => {
+ expect(pipe.transform(false)).toBe(false);
+ });
+
+ it('transforms to false [3/4]', () => {
+ expect(pipe.transform('bar')).toBe(false);
+ });
+
+ it('transforms to false [4/4]', () => {
+ expect(pipe.transform(2)).toBe(false);
+ });
+
+ it('transforms to true [1/8]', () => {
+ expect(pipe.transform(true)).toBe(true);
+ });
+
+ it('transforms to true [2/8]', () => {
+ expect(pipe.transform(1)).toBe(true);
+ });
+
+ it('transforms to true [3/8]', () => {
+ expect(pipe.transform('y')).toBe(true);
+ });
+
+ it('transforms to true [4/8]', () => {
+ expect(pipe.transform('yes')).toBe(true);
+ });
+
+ it('transforms to true [5/8]', () => {
+ expect(pipe.transform('t')).toBe(true);
+ });
+
+ it('transforms to true [6/8]', () => {
+ expect(pipe.transform('true')).toBe(true);
+ });
+
+ it('transforms to true [7/8]', () => {
+ expect(pipe.transform('on')).toBe(true);
+ });
+
+ it('transforms to true [8/8]', () => {
+ expect(pipe.transform('1')).toBe(true);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts
new file mode 100755
index 000000000..b94a40bc4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts
@@ -0,0 +1,26 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+/**
+ * Convert the given value to a boolean value.
+ */
+@Pipe({
+ name: 'boolean'
+})
+export class BooleanPipe implements PipeTransform {
+ transform(value: any): boolean {
+ let result = false;
+ switch (value) {
+ case true:
+ case 1:
+ case 'y':
+ case 'yes':
+ case 't':
+ case 'true':
+ case 'on':
+ case '1':
+ result = true;
+ break;
+ }
+ return result;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts
new file mode 100644
index 000000000..b67ed62c8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts
@@ -0,0 +1,24 @@
+import { DatePipe } from '@angular/common';
+
+import moment from 'moment';
+
+import { CdDatePipe } from './cd-date.pipe';
+
+describe('CdDatePipe', () => {
+ const datePipe = new DatePipe('en-US');
+ let pipe = new CdDatePipe(datePipe);
+
+ it('create an instance', () => {
+ pipe = new CdDatePipe(datePipe);
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform('')).toBe('');
+ });
+
+ it('transforms with some date', () => {
+ const result = moment(1527085564486).format('M/D/YY LTS');
+ expect(pipe.transform(1527085564486)).toBe(result);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts
new file mode 100644
index 000000000..911f32041
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts
@@ -0,0 +1,20 @@
+import { DatePipe } from '@angular/common';
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'cdDate'
+})
+export class CdDatePipe implements PipeTransform {
+ constructor(private datePipe: DatePipe) {}
+
+ transform(value: any): any {
+ if (value === null || value === '') {
+ return '';
+ }
+ return (
+ this.datePipe.transform(value, 'shortDate') +
+ ' ' +
+ this.datePipe.transform(value, 'mediumTime')
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts
new file mode 100644
index 000000000..3e1f1f7ca
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts
@@ -0,0 +1,28 @@
+import { CephReleaseNamePipe } from './ceph-release-name.pipe';
+
+describe('CephReleaseNamePipe', () => {
+ const pipe = new CephReleaseNamePipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('recognizes a stable release', () => {
+ const value =
+ 'ceph version 13.2.1 \
+ (5533ecdc0fda920179d7ad84e0aa65a127b20d77) mimic (stable)';
+ expect(pipe.transform(value)).toBe('mimic');
+ });
+
+ it('recognizes a development release as the main branch', () => {
+ const value =
+ 'ceph version 13.1.0-534-g23d3751b89 \
+ (23d3751b897b31d2bda57aeaf01acb5ff3c4a9cd) nautilus (dev)';
+ expect(pipe.transform(value)).toBe('main');
+ });
+
+ it('transforms with wrong version format', () => {
+ const value = 'foo';
+ expect(pipe.transform(value)).toBe('foo');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts
new file mode 100644
index 000000000..c63c794a9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts
@@ -0,0 +1,24 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'cephReleaseName'
+})
+export class CephReleaseNamePipe implements PipeTransform {
+ transform(value: any): any {
+ // Expect "ceph version 13.1.0-419-g251e2515b5
+ // (251e2515b563856349498c6caf34e7a282f62937) nautilus (dev)"
+ const result = /ceph version\s+[^ ]+\s+\(.+\)\s+(.+)\s+\((.+)\)/.exec(value);
+ if (result) {
+ if (result[2] === 'dev') {
+ // Assume this is actually main
+ return 'main';
+ } else {
+ // Return the "nautilus" part
+ return result[1];
+ }
+ } else {
+ // Unexpected format, pass it through
+ return value;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts
new file mode 100644
index 000000000..0242839df
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts
@@ -0,0 +1,21 @@
+import { CephShortVersionPipe } from './ceph-short-version.pipe';
+
+describe('CephShortVersionPipe', () => {
+ const pipe = new CephShortVersionPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms with correct version format', () => {
+ const value =
+ 'ceph version 13.1.0-534-g23d3751b89 \
+ (23d3751b897b31d2bda57aeaf01acb5ff3c4a9cd) nautilus (dev)';
+ expect(pipe.transform(value)).toBe('13.1.0-534-g23d3751b89');
+ });
+
+ it('transforms with wrong version format', () => {
+ const value = 'foo';
+ expect(pipe.transform(value)).toBe('foo');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts
new file mode 100644
index 000000000..03e75dfb3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts
@@ -0,0 +1,18 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'cephShortVersion'
+})
+export class CephShortVersionPipe implements PipeTransform {
+ transform(value: any): any {
+ // Expect "ceph version 1.2.3-g9asdasd (as98d7a0s8d7)"
+ const result = /ceph version\s+([^ ]+)\s+\(.+\)/.exec(value);
+ if (result) {
+ // Return the "1.2.3-g9asdasd" part
+ return result[1];
+ } else {
+ // Unexpected format, pass it through
+ return value;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts
new file mode 100644
index 000000000..21b596317
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts
@@ -0,0 +1,24 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+ name: 'dimlessBinaryPerSecond'
+})
+export class DimlessBinaryPerSecondPipe implements PipeTransform {
+ constructor(private formatter: FormatterService) {}
+
+ transform(value: any): any {
+ return this.formatter.format_number(value, 1024, [
+ 'B/s',
+ 'kB/s',
+ 'MB/s',
+ 'GB/s',
+ 'TB/s',
+ 'PB/s',
+ 'EB/s',
+ 'ZB/s',
+ 'YB/s'
+ ]);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts
new file mode 100644
index 000000000..caf51f578
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts
@@ -0,0 +1,56 @@
+import { FormatterService } from '../services/formatter.service';
+import { DimlessBinaryPipe } from './dimless-binary.pipe';
+
+describe('DimlessBinaryPipe', () => {
+ const formatterService = new FormatterService();
+ const pipe = new DimlessBinaryPipe(formatterService);
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms 1024^0', () => {
+ const value = Math.pow(1024, 0);
+ expect(pipe.transform(value)).toBe('1 B');
+ });
+
+ it('transforms 1024^1', () => {
+ const value = Math.pow(1024, 1);
+ expect(pipe.transform(value)).toBe('1 KiB');
+ });
+
+ it('transforms 1024^2', () => {
+ const value = Math.pow(1024, 2);
+ expect(pipe.transform(value)).toBe('1 MiB');
+ });
+
+ it('transforms 1024^3', () => {
+ const value = Math.pow(1024, 3);
+ expect(pipe.transform(value)).toBe('1 GiB');
+ });
+
+ it('transforms 1024^4', () => {
+ const value = Math.pow(1024, 4);
+ expect(pipe.transform(value)).toBe('1 TiB');
+ });
+
+ it('transforms 1024^5', () => {
+ const value = Math.pow(1024, 5);
+ expect(pipe.transform(value)).toBe('1 PiB');
+ });
+
+ it('transforms 1024^6', () => {
+ const value = Math.pow(1024, 6);
+ expect(pipe.transform(value)).toBe('1 EiB');
+ });
+
+ it('transforms 1024^7', () => {
+ const value = Math.pow(1024, 7);
+ expect(pipe.transform(value)).toBe('1 ZiB');
+ });
+
+ it('transforms 1024^8', () => {
+ const value = Math.pow(1024, 8);
+ expect(pipe.transform(value)).toBe('1 YiB');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts
new file mode 100644
index 000000000..cf5d2cdec
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts
@@ -0,0 +1,24 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+ name: 'dimlessBinary'
+})
+export class DimlessBinaryPipe implements PipeTransform {
+ constructor(private formatter: FormatterService) {}
+
+ transform(value: any): any {
+ return this.formatter.format_number(value, 1024, [
+ 'B',
+ 'KiB',
+ 'MiB',
+ 'GiB',
+ 'TiB',
+ 'PiB',
+ 'EiB',
+ 'ZiB',
+ 'YiB'
+ ]);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts
new file mode 100644
index 000000000..8d01678f7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts
@@ -0,0 +1,56 @@
+import { FormatterService } from '../services/formatter.service';
+import { DimlessPipe } from './dimless.pipe';
+
+describe('DimlessPipe', () => {
+ const formatterService = new FormatterService();
+ const pipe = new DimlessPipe(formatterService);
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms 1000^0', () => {
+ const value = Math.pow(1000, 0);
+ expect(pipe.transform(value)).toBe('1');
+ });
+
+ it('transforms 1000^1', () => {
+ const value = Math.pow(1000, 1);
+ expect(pipe.transform(value)).toBe('1 k');
+ });
+
+ it('transforms 1000^2', () => {
+ const value = Math.pow(1000, 2);
+ expect(pipe.transform(value)).toBe('1 M');
+ });
+
+ it('transforms 1000^3', () => {
+ const value = Math.pow(1000, 3);
+ expect(pipe.transform(value)).toBe('1 G');
+ });
+
+ it('transforms 1000^4', () => {
+ const value = Math.pow(1000, 4);
+ expect(pipe.transform(value)).toBe('1 T');
+ });
+
+ it('transforms 1000^5', () => {
+ const value = Math.pow(1000, 5);
+ expect(pipe.transform(value)).toBe('1 P');
+ });
+
+ it('transforms 1000^6', () => {
+ const value = Math.pow(1000, 6);
+ expect(pipe.transform(value)).toBe('1 E');
+ });
+
+ it('transforms 1000^7', () => {
+ const value = Math.pow(1000, 7);
+ expect(pipe.transform(value)).toBe('1 Z');
+ });
+
+ it('transforms 1000^8', () => {
+ const value = Math.pow(1000, 8);
+ expect(pipe.transform(value)).toBe('1 Y');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts
new file mode 100644
index 000000000..1be11590d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts
@@ -0,0 +1,14 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+ name: 'dimless'
+})
+export class DimlessPipe implements PipeTransform {
+ constructor(private formatter: FormatterService) {}
+
+ transform(value: any): any {
+ return this.formatter.format_number(value, 1000, ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts
new file mode 100644
index 000000000..1b0e22578
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts
@@ -0,0 +1,17 @@
+import { DurationPipe } from './duration.pipe';
+
+describe('DurationPipe', () => {
+ const pipe = new DurationPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms seconds into a human readable duration', () => {
+ expect(pipe.transform(0)).toBe('1 second');
+ expect(pipe.transform(6)).toBe('6 seconds');
+ expect(pipe.transform(60)).toBe('1 minute');
+ expect(pipe.transform(600)).toBe('10 minutes');
+ expect(pipe.transform(6000)).toBe('1 hour 40 minutes');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts
new file mode 100644
index 000000000..4675fc0f6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts
@@ -0,0 +1,37 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'duration',
+ pure: false
+})
+export class DurationPipe implements PipeTransform {
+ /**
+ * Translates seconds into human readable format of seconds, minutes, hours, days, and years
+ * source: https://stackoverflow.com/a/34270811
+ *
+ * @param {number} seconds The number of seconds to be processed
+ * @return {string} The phrase describing the the amount of time
+ */
+ transform(seconds: number): string {
+ const levels = [
+ [`${Math.floor(seconds / 31536000)}`, 'years'],
+ [`${Math.floor((seconds % 31536000) / 86400)}`, 'days'],
+ [`${Math.floor((seconds % 86400) / 3600)}`, 'hours'],
+ [`${Math.floor((seconds % 3600) / 60)}`, 'minutes'],
+ [`${Math.floor(seconds % 60)}`, 'seconds']
+ ];
+ let returntext = '';
+
+ for (let i = 0, max = levels.length; i < max; i++) {
+ if (levels[i][0] === '0') {
+ continue;
+ }
+ returntext +=
+ ' ' +
+ levels[i][0] +
+ ' ' +
+ (levels[i][0] === '1' ? levels[i][1].substr(0, levels[i][1].length - 1) : levels[i][1]);
+ }
+ return returntext.trim() || '1 second';
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts
new file mode 100644
index 000000000..e73420f6a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts
@@ -0,0 +1,18 @@
+import { EmptyPipe } from './empty.pipe';
+
+describe('EmptyPipe', () => {
+ const pipe = new EmptyPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms with empty value', () => {
+ expect(pipe.transform(undefined)).toBe('-');
+ });
+
+ it('transforms with some value', () => {
+ const value = 'foo';
+ expect(pipe.transform(value)).toBe('foo');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts
new file mode 100644
index 000000000..fb753e8d9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts
@@ -0,0 +1,12 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'empty'
+})
+export class EmptyPipe implements PipeTransform {
+ transform(value: any): any {
+ return _.isUndefined(value) || _.isNull(value) ? '-' : value;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts
new file mode 100644
index 000000000..a43674093
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts
@@ -0,0 +1,13 @@
+import { EncodeUriPipe } from './encode-uri.pipe';
+
+describe('EncodeUriPipe', () => {
+ it('create an instance', () => {
+ const pipe = new EncodeUriPipe();
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should transforms the value', () => {
+ const pipe = new EncodeUriPipe();
+ expect(pipe.transform('rbd/name')).toBe('rbd%2Fname');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts
new file mode 100644
index 000000000..48fbf1668
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'encodeUri'
+})
+export class EncodeUriPipe implements PipeTransform {
+ transform(value: any): any {
+ return encodeURIComponent(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts
new file mode 100644
index 000000000..58d7ff95f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts
@@ -0,0 +1,54 @@
+import { FilterPipe } from './filter.pipe';
+
+describe('FilterPipe', () => {
+ const pipe = new FilterPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('filter words with "foo"', () => {
+ const value = ['foo', 'bar', 'foobar'];
+ const filters = [
+ {
+ value: 'foo',
+ applyFilter: (row: any[], val: any) => {
+ return row.indexOf(val) !== -1;
+ }
+ }
+ ];
+ expect(pipe.transform(value, filters)).toEqual(['foo', 'foobar']);
+ });
+
+ it('filter words with "foo" and "bar"', () => {
+ const value = ['foo', 'bar', 'foobar'];
+ const filters = [
+ {
+ value: 'foo',
+ applyFilter: (row: any[], val: any) => {
+ return row.indexOf(val) !== -1;
+ }
+ },
+ {
+ value: 'bar',
+ applyFilter: (row: any[], val: any) => {
+ return row.indexOf(val) !== -1;
+ }
+ }
+ ];
+ expect(pipe.transform(value, filters)).toEqual(['foobar']);
+ });
+
+ it('filter with no value', () => {
+ const value = ['foo', 'bar', 'foobar'];
+ const filters = [
+ {
+ value: '',
+ applyFilter: () => {
+ return false;
+ }
+ }
+ ];
+ expect(pipe.transform(value, filters)).toEqual(['foo', 'bar', 'foobar']);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts
new file mode 100644
index 000000000..313ac4c0d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts
@@ -0,0 +1,25 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'filter'
+})
+export class FilterPipe implements PipeTransform {
+ transform(value: any, args?: any): any {
+ return value.filter((row: any) => {
+ let result = true;
+
+ args.forEach((filter: any): boolean | void => {
+ if (!filter.value) {
+ return undefined;
+ }
+
+ result = result && filter.applyFilter(row, filter.value);
+ if (!result) {
+ return result;
+ }
+ });
+
+ return result;
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts
new file mode 100644
index 000000000..f5e937ce3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts
@@ -0,0 +1,47 @@
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { HealthColorPipe } from '~/app/shared/pipes/health-color.pipe';
+
+class CssHelperStub extends CssHelper {
+ propertyValue(propertyName: string) {
+ if (propertyName === 'health-color-healthy') {
+ return 'fakeGreen';
+ }
+ if (propertyName === 'health-color-warning') {
+ return 'fakeOrange';
+ }
+ if (propertyName === 'health-color-error') {
+ return 'fakeRed';
+ }
+ return '';
+ }
+}
+
+describe('HealthColorPipe', () => {
+ const pipe = new HealthColorPipe(new CssHelperStub());
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "HEALTH_OK"', () => {
+ expect(pipe.transform('HEALTH_OK')).toEqual({
+ color: 'fakeGreen'
+ });
+ });
+
+ it('transforms "HEALTH_WARN"', () => {
+ expect(pipe.transform('HEALTH_WARN')).toEqual({
+ color: 'fakeOrange'
+ });
+ });
+
+ it('transforms "HEALTH_ERR"', () => {
+ expect(pipe.transform('HEALTH_ERR')).toEqual({
+ color: 'fakeRed'
+ });
+ });
+
+ it('transforms others', () => {
+ expect(pipe.transform('abc')).toBe(null);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts
new file mode 100644
index 000000000..d046fa15a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts
@@ -0,0 +1,17 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { HealthColor } from '~/app/shared/enum/health-color.enum';
+
+@Pipe({
+ name: 'healthColor'
+})
+export class HealthColorPipe implements PipeTransform {
+ constructor(private cssHelper: CssHelper) {}
+
+ transform(value: any): any {
+ return Object.keys(HealthColor).includes(value as HealthColor)
+ ? { color: this.cssHelper.propertyValue(HealthColor[value]) }
+ : null;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts
new file mode 100644
index 000000000..dac353ddf
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts
@@ -0,0 +1,8 @@
+import { IopsPipe } from './iops.pipe';
+
+describe('IopsPipe', () => {
+ it('create an instance', () => {
+ const pipe = new IopsPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts
new file mode 100644
index 000000000..9644801f8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'iops'
+})
+export class IopsPipe implements PipeTransform {
+ transform(value: any): any {
+ return `${value} IOPS`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts
new file mode 100644
index 000000000..c82e37554
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts
@@ -0,0 +1,17 @@
+import { IscsiBackstorePipe } from './iscsi-backstore.pipe';
+
+describe('IscsiBackstorePipe', () => {
+ const pipe = new IscsiBackstorePipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "user:rbd"', () => {
+ expect(pipe.transform('user:rbd')).toBe('user:rbd (tcmu-runner)');
+ });
+
+ it('transforms "other"', () => {
+ expect(pipe.transform('other')).toBe('other');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts
new file mode 100644
index 000000000..19a0d66c1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'iscsiBackstore'
+})
+export class IscsiBackstorePipe implements PipeTransform {
+ transform(value: any): any {
+ switch (value) {
+ case 'user:rbd':
+ return 'user:rbd (tcmu-runner)';
+ default:
+ return value;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts
new file mode 100644
index 000000000..01bccbc2d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts
@@ -0,0 +1,13 @@
+import { JoinPipe } from './join.pipe';
+
+describe('ListPipe', () => {
+ const pipe = new JoinPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "[1,2,3]"', () => {
+ expect(pipe.transform([1, 2, 3])).toBe('1, 2, 3');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts
new file mode 100644
index 000000000..68610846e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'join'
+})
+export class JoinPipe implements PipeTransform {
+ transform(value: Array<any>): string {
+ return value.join(', ');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts
new file mode 100644
index 000000000..45d677c2a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts
@@ -0,0 +1,32 @@
+import { LogPriorityPipe } from './log-priority.pipe';
+
+describe('LogPriorityPipe', () => {
+ const pipe = new LogPriorityPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "INF"', () => {
+ const value = '[INF]';
+ const result = 'info';
+ expect(pipe.transform(value)).toEqual(result);
+ });
+
+ it('transforms "WRN"', () => {
+ const value = '[WRN]';
+ const result = 'warn';
+ expect(pipe.transform(value)).toEqual(result);
+ });
+
+ it('transforms "ERR"', () => {
+ const value = '[ERR]';
+ const result = 'err';
+ expect(pipe.transform(value)).toEqual(result);
+ });
+
+ it('transforms others', () => {
+ const value = '[foo]';
+ expect(pipe.transform(value)).toBe('');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts
new file mode 100644
index 000000000..0c51c867b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts
@@ -0,0 +1,20 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'logPriority'
+})
+export class LogPriorityPipe implements PipeTransform {
+ transform(value: any): any {
+ if (value === '[DBG]') {
+ return 'debug';
+ } else if (value === '[INF]') {
+ return 'info';
+ } else if (value === '[WRN]') {
+ return 'warn';
+ } else if (value === '[ERR]') {
+ return 'err';
+ } else {
+ return ''; // Inherit
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts
new file mode 100644
index 000000000..337d5c37b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts
@@ -0,0 +1,25 @@
+import { MapPipe } from './map.pipe';
+
+describe('MapPipe', () => {
+ const pipe = new MapPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('map value [1]', () => {
+ expect(pipe.transform('foo')).toBe('foo');
+ });
+
+ it('map value [2]', () => {
+ expect(pipe.transform('foo', { '-1': 'disabled', 0: 'unlimited' })).toBe('foo');
+ });
+
+ it('map value [3]', () => {
+ expect(pipe.transform(-1, { '-1': 'disabled', 0: 'unlimited' })).toBe('disabled');
+ });
+
+ it('map value [4]', () => {
+ expect(pipe.transform(0, { '-1': 'disabled', 0: 'unlimited' })).toBe('unlimited');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts
new file mode 100644
index 000000000..1c0839d08
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'map'
+})
+export class MapPipe implements PipeTransform {
+ transform(value: string | number, map?: object): any {
+ if (!_.isPlainObject(map)) {
+ return value;
+ }
+ return _.get(map, value, value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts
new file mode 100644
index 000000000..cea4bb13f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts
@@ -0,0 +1,8 @@
+import { MillisecondsPipe } from './milliseconds.pipe';
+
+describe('MillisecondsPipe', () => {
+ it('create an instance', () => {
+ const pipe = new MillisecondsPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts
new file mode 100644
index 000000000..b0dc68604
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'milliseconds'
+})
+export class MillisecondsPipe implements PipeTransform {
+ transform(value: any): any {
+ return `${value} ms`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts
new file mode 100644
index 000000000..06279a5ea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts
@@ -0,0 +1,30 @@
+import { NotAvailablePipe } from './not-available.pipe';
+
+describe('NotAvailablePipe', () => {
+ let pipe: NotAvailablePipe;
+
+ beforeEach(() => {
+ pipe = new NotAvailablePipe();
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms not available (1)', () => {
+ expect(pipe.transform('')).toBe('n/a');
+ });
+
+ it('transforms not available (2)', () => {
+ expect(pipe.transform('', 'Unknown')).toBe('Unknown');
+ });
+
+ it('transform not necessary (1)', () => {
+ expect(pipe.transform(0)).toBe(0);
+ expect(pipe.transform(1)).toBe(1);
+ });
+
+ it('transform not necessary (2)', () => {
+ expect(pipe.transform('foo')).toBe('foo');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts
new file mode 100644
index 000000000..9d0222724
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'notAvailable'
+})
+export class NotAvailablePipe implements PipeTransform {
+ transform(value: any, text?: string): any {
+ if (value === '') {
+ return _.defaultTo(text, $localize`n/a`);
+ }
+ return value;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts
new file mode 100644
index 000000000..7e1cdbc8d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts
@@ -0,0 +1,8 @@
+import { OrdinalPipe } from './ordinal.pipe';
+
+describe('OrdinalPipe', () => {
+ it('create an instance', () => {
+ const pipe = new OrdinalPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts
new file mode 100644
index 000000000..da89a0240
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts
@@ -0,0 +1,25 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'ordinal'
+})
+export class OrdinalPipe implements PipeTransform {
+ transform(value: any): any {
+ const num = parseInt(value, 10);
+ if (isNaN(num)) {
+ return value;
+ }
+ return (
+ value +
+ (Math.floor(num / 10) === 1
+ ? 'th'
+ : num % 10 === 1
+ ? 'st'
+ : num % 10 === 2
+ ? 'nd'
+ : num % 10 === 3
+ ? 'rd'
+ : 'th')
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
new file mode 100755
index 000000000..508a29e98
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
@@ -0,0 +1,125 @@
+import { CommonModule, DatePipe } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { ArrayPipe } from './array.pipe';
+import { BooleanTextPipe } from './boolean-text.pipe';
+import { BooleanPipe } from './boolean.pipe';
+import { CdDatePipe } from './cd-date.pipe';
+import { CephReleaseNamePipe } from './ceph-release-name.pipe';
+import { CephShortVersionPipe } from './ceph-short-version.pipe';
+import { DimlessBinaryPerSecondPipe } from './dimless-binary-per-second.pipe';
+import { DimlessBinaryPipe } from './dimless-binary.pipe';
+import { DimlessPipe } from './dimless.pipe';
+import { DurationPipe } from './duration.pipe';
+import { EmptyPipe } from './empty.pipe';
+import { EncodeUriPipe } from './encode-uri.pipe';
+import { FilterPipe } from './filter.pipe';
+import { HealthColorPipe } from './health-color.pipe';
+import { IopsPipe } from './iops.pipe';
+import { IscsiBackstorePipe } from './iscsi-backstore.pipe';
+import { JoinPipe } from './join.pipe';
+import { LogPriorityPipe } from './log-priority.pipe';
+import { MapPipe } from './map.pipe';
+import { MillisecondsPipe } from './milliseconds.pipe';
+import { NotAvailablePipe } from './not-available.pipe';
+import { OrdinalPipe } from './ordinal.pipe';
+import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe';
+import { RelativeDatePipe } from './relative-date.pipe';
+import { RoundPipe } from './round.pipe';
+import { SanitizeHtmlPipe } from './sanitize-html.pipe';
+import { SearchHighlightPipe } from './search-highlight.pipe';
+import { TruncatePipe } from './truncate.pipe';
+import { UpperFirstPipe } from './upper-first.pipe';
+
+@NgModule({
+ imports: [CommonModule],
+ declarations: [
+ ArrayPipe,
+ BooleanPipe,
+ BooleanTextPipe,
+ DimlessBinaryPipe,
+ DimlessBinaryPerSecondPipe,
+ HealthColorPipe,
+ DimlessPipe,
+ CephShortVersionPipe,
+ CephReleaseNamePipe,
+ RelativeDatePipe,
+ IscsiBackstorePipe,
+ JoinPipe,
+ LogPriorityPipe,
+ FilterPipe,
+ CdDatePipe,
+ EmptyPipe,
+ EncodeUriPipe,
+ RoundPipe,
+ OrdinalPipe,
+ MillisecondsPipe,
+ NotAvailablePipe,
+ IopsPipe,
+ UpperFirstPipe,
+ RbdConfigurationSourcePipe,
+ DurationPipe,
+ MapPipe,
+ TruncatePipe,
+ SanitizeHtmlPipe,
+ SearchHighlightPipe
+ ],
+ exports: [
+ ArrayPipe,
+ BooleanPipe,
+ BooleanTextPipe,
+ DimlessBinaryPipe,
+ DimlessBinaryPerSecondPipe,
+ HealthColorPipe,
+ DimlessPipe,
+ CephShortVersionPipe,
+ CephReleaseNamePipe,
+ RelativeDatePipe,
+ IscsiBackstorePipe,
+ JoinPipe,
+ LogPriorityPipe,
+ FilterPipe,
+ CdDatePipe,
+ EmptyPipe,
+ EncodeUriPipe,
+ RoundPipe,
+ OrdinalPipe,
+ MillisecondsPipe,
+ NotAvailablePipe,
+ IopsPipe,
+ UpperFirstPipe,
+ RbdConfigurationSourcePipe,
+ DurationPipe,
+ MapPipe,
+ TruncatePipe,
+ SanitizeHtmlPipe,
+ SearchHighlightPipe
+ ],
+ providers: [
+ ArrayPipe,
+ BooleanPipe,
+ BooleanTextPipe,
+ DatePipe,
+ CephShortVersionPipe,
+ CephReleaseNamePipe,
+ DimlessBinaryPipe,
+ DimlessBinaryPerSecondPipe,
+ DimlessPipe,
+ RelativeDatePipe,
+ IscsiBackstorePipe,
+ JoinPipe,
+ LogPriorityPipe,
+ CdDatePipe,
+ EmptyPipe,
+ EncodeUriPipe,
+ OrdinalPipe,
+ IopsPipe,
+ MillisecondsPipe,
+ NotAvailablePipe,
+ UpperFirstPipe,
+ MapPipe,
+ TruncatePipe,
+ SanitizeHtmlPipe
+ ]
+})
+export class PipesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts
new file mode 100644
index 000000000..9c0346bd6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts
@@ -0,0 +1,22 @@
+import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe';
+
+describe('RbdConfigurationSourcePipePipe', () => {
+ let pipe: RbdConfigurationSourcePipe;
+
+ beforeEach(() => {
+ pipe = new RbdConfigurationSourcePipe();
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should transform correctly', () => {
+ expect(pipe.transform('foo')).not.toBeDefined();
+ expect(pipe.transform(-1)).not.toBeDefined();
+ expect(pipe.transform(0)).toBe('global');
+ expect(pipe.transform(1)).toBe('pool');
+ expect(pipe.transform(2)).toBe('image');
+ expect(pipe.transform(-3)).not.toBeDefined();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts
new file mode 100644
index 000000000..bb42d3f1c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'rbdConfigurationSource'
+})
+export class RbdConfigurationSourcePipe implements PipeTransform {
+ transform(value: any): any {
+ const sourceMap = {
+ 0: 'global',
+ 1: 'pool',
+ 2: 'image'
+ };
+ return sourceMap[value];
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts
new file mode 100644
index 000000000..a12d3c2a1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts
@@ -0,0 +1,44 @@
+import moment from 'moment';
+
+import { RelativeDatePipe } from './relative-date.pipe';
+
+describe('RelativeDatePipe', () => {
+ const pipe = new RelativeDatePipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms date into a human readable relative time (1)', () => {
+ const date: Date = moment().subtract(130, 'seconds').toDate();
+ expect(pipe.transform(date)).toBe('2 minutes ago');
+ });
+
+ it('transforms date into a human readable relative time (2)', () => {
+ const date: Date = moment().subtract(65, 'minutes').toDate();
+ expect(pipe.transform(date)).toBe('An hour ago');
+ });
+
+ it('transforms date into a human readable relative time (3)', () => {
+ const date: string = moment().subtract(130, 'minutes').toISOString();
+ expect(pipe.transform(date)).toBe('2 hours ago');
+ });
+
+ it('transforms date into a human readable relative time (4)', () => {
+ const date: string = moment().subtract(30, 'seconds').toISOString();
+ expect(pipe.transform(date, false)).toBe('a few seconds ago');
+ });
+
+ it('transforms date into a human readable relative time (5)', () => {
+ const date: number = moment().subtract(3, 'days').unix();
+ expect(pipe.transform(date)).toBe('3 days ago');
+ });
+
+ it('invalid input (1)', () => {
+ expect(pipe.transform('')).toBe('');
+ });
+
+ it('invalid input (2)', () => {
+ expect(pipe.transform('2011-10-10T10:20:90')).toBe('');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts
new file mode 100644
index 000000000..f802b6b2a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts
@@ -0,0 +1,57 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+import moment from 'moment';
+
+moment.updateLocale('en', {
+ relativeTime: {
+ future: $localize`in %s`,
+ past: $localize`%s ago`,
+ s: $localize`a few seconds`,
+ ss: $localize`%d seconds`,
+ m: $localize`a minute`,
+ mm: $localize`%d minutes`,
+ h: $localize`an hour`,
+ hh: $localize`%d hours`,
+ d: $localize`a day`,
+ dd: $localize`%d days`,
+ w: $localize`a week`,
+ ww: $localize`%d weeks`,
+ M: $localize`a month`,
+ MM: $localize`%d months`,
+ y: $localize`a year`,
+ yy: $localize`%d years`
+ }
+});
+
+@Pipe({
+ name: 'relativeDate',
+ pure: false
+})
+export class RelativeDatePipe implements PipeTransform {
+ /**
+ * Convert a time into a human readable form, e.g. '2 minutes ago'.
+ * @param {Date | string | number} value The date to convert, should be
+ * an ISO8601 string, an Unix timestamp (seconds) or Date object.
+ * @param {boolean} upperFirst Set to `true` to start the sentence
+ * upper case. Defaults to `true`.
+ * @return {string} The time in human readable form or an empty string
+ * on failure (e.g. invalid input).
+ */
+ transform(value: Date | string | number, upperFirst = true): string {
+ let date: moment.Moment;
+ if (_.isNumber(value)) {
+ date = moment.unix(value);
+ } else {
+ date = moment(value);
+ }
+ if (!date.isValid()) {
+ return '';
+ }
+ let relativeDate: string = date.fromNow();
+ if (upperFirst) {
+ relativeDate = _.upperFirst(relativeDate);
+ }
+ return relativeDate;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts
new file mode 100644
index 000000000..602045263
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts
@@ -0,0 +1,13 @@
+import { RoundPipe } from './round.pipe';
+
+describe('RoundPipe', () => {
+ const pipe = new RoundPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "1500"', () => {
+ expect(pipe.transform(1.52, 1)).toEqual(1.5);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts
new file mode 100644
index 000000000..077831ac2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts
@@ -0,0 +1,12 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'round'
+})
+export class RoundPipe implements PipeTransform {
+ transform(value: any, precision: number): any {
+ return _.round(value, precision);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts
new file mode 100644
index 000000000..719f32ee5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts
@@ -0,0 +1,26 @@
+import { TestBed } from '@angular/core/testing';
+import { DomSanitizer } from '@angular/platform-browser';
+
+import { SanitizeHtmlPipe } from '~/app/shared/pipes/sanitize-html.pipe';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('SanitizeHtmlPipe', () => {
+ let pipe: SanitizeHtmlPipe;
+ let domSanitizer: DomSanitizer;
+
+ configureTestBed({
+ providers: [DomSanitizer]
+ });
+
+ beforeEach(() => {
+ domSanitizer = TestBed.inject(DomSanitizer);
+ pipe = new SanitizeHtmlPipe(domSanitizer);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ // There is no way to inject a working DomSanitizer in unit tests,
+ // so it is not possible to test the `transform` method.
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts
new file mode 100644
index 000000000..f6a8b0c9e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts
@@ -0,0 +1,13 @@
+import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
+import { DomSanitizer, SafeValue } from '@angular/platform-browser';
+
+@Pipe({
+ name: 'sanitizeHtml'
+})
+export class SanitizeHtmlPipe implements PipeTransform {
+ constructor(private domSanitizer: DomSanitizer) {}
+
+ transform(value: SafeValue | string | null): string | null {
+ return this.domSanitizer.sanitize(SecurityContext.HTML, value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.spec.ts
new file mode 100644
index 000000000..73f8e55ed
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.spec.ts
@@ -0,0 +1,41 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SearchHighlightPipe } from './search-highlight.pipe';
+
+describe('SearchHighlightPipe', () => {
+ let pipe: SearchHighlightPipe;
+
+ configureTestBed({
+ providers: [SearchHighlightPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(SearchHighlightPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms with a matching keyword ', () => {
+ const value = 'overall HEALTH_WARN Dashboard debug mode is enabled';
+ const args = 'Dashboard';
+ const expected = 'overall HEALTH_WARN <mark>Dashboard</mark> debug mode is enabled';
+
+ expect(pipe.transform(value, args)).toEqual(expected);
+ });
+
+ it('transforms with a matching keyword having regex character', () => {
+ const value = 'loreum ipsum .? dolor sit amet';
+ const args = '.?';
+ const expected = 'loreum ipsum <mark>.?</mark> dolor sit amet';
+
+ expect(pipe.transform(value, args)).toEqual(expected);
+ });
+
+ it('transforms with empty search keyword', () => {
+ const value = 'overall HEALTH_WARN Dashboard debug mode is enabled';
+ expect(pipe.transform(value, '')).toBe(value);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts
new file mode 100644
index 000000000..c00cc46c6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts
@@ -0,0 +1,26 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'searchHighlight'
+})
+export class SearchHighlightPipe implements PipeTransform {
+ transform(value: string, args: string): string {
+ if (!args) {
+ return value;
+ }
+ args = this.escapeRegExp(args);
+ const regex = new RegExp(args, 'gi');
+ const match = value.match(regex);
+
+ if (!match) {
+ return value;
+ }
+
+ return value.replace(regex, '<mark>$&</mark>');
+ }
+
+ private escapeRegExp(str: string) {
+ // $& means the whole matched string
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts
new file mode 100644
index 000000000..cc0b2fc70
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts
@@ -0,0 +1,21 @@
+import { TruncatePipe } from './truncate.pipe';
+
+describe('TruncatePipe', () => {
+ const pipe = new TruncatePipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should truncate string (1)', () => {
+ expect(pipe.transform('fsdfdsfs asdasd', 5, '')).toEqual('fsdfd');
+ });
+
+ it('should truncate string (2)', () => {
+ expect(pipe.transform('fsdfdsfs asdasd', 10, '...')).toEqual('fsdfdsf...');
+ });
+
+ it('should not truncate number', () => {
+ expect(pipe.transform(2, 6, '...')).toBe(2);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts
new file mode 100644
index 000000000..ff49c6386
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts
@@ -0,0 +1,16 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'truncate'
+})
+export class TruncatePipe implements PipeTransform {
+ transform(value: any, length: number, omission?: string): any {
+ if (!_.isString(value)) {
+ return value;
+ }
+ omission = _.defaultTo(omission, '');
+ return _.truncate(value, { length, omission });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts
new file mode 100644
index 000000000..072baa04b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts
@@ -0,0 +1,17 @@
+import { UpperFirstPipe } from './upper-first.pipe';
+
+describe('UpperFirstPipe', () => {
+ const pipe = new UpperFirstPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "foo"', () => {
+ expect(pipe.transform('foo')).toEqual('Foo');
+ });
+
+ it('transforms "BAR"', () => {
+ expect(pipe.transform('BAR')).toEqual('BAR');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts
new file mode 100644
index 000000000..b73b1bc20
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts
@@ -0,0 +1,12 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'upperFirst'
+})
+export class UpperFirstPipe implements PipeTransform {
+ transform(value: string): string {
+ return _.upperFirst(value);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts
new file mode 100644
index 000000000..22644dcf2
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts
@@ -0,0 +1,20 @@
+import { fromEvent, Observable, partition } from 'rxjs';
+import { repeatWhen, shareReplay, takeUntil } from 'rxjs/operators';
+
+export function whenPageVisible() {
+ const visibilitychange$ = fromEvent(document, 'visibilitychange').pipe(
+ shareReplay({ refCount: true, bufferSize: 1 })
+ );
+
+ const [pageVisible$, pageHidden$] = partition(
+ visibilitychange$,
+ () => document.visibilityState === 'visible'
+ );
+
+ return function <T>(source: Observable<T>) {
+ return source.pipe(
+ takeUntil(pageHidden$),
+ repeatWhen(() => pageVisible$)
+ );
+ };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts
new file mode 100644
index 000000000..ba7c30f49
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts
@@ -0,0 +1,227 @@
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
+
+import { ToastrService } from 'ngx-toastr';
+
+import { AppModule } from '~/app/app.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
+import { ApiInterceptorService } from './api-interceptor.service';
+import { NotificationService } from './notification.service';
+
+describe('ApiInterceptorService', () => {
+ let notificationService: NotificationService;
+ let httpTesting: HttpTestingController;
+ let httpClient: HttpClient;
+ let router: Router;
+ const url = 'api/xyz';
+
+ const httpError = (error: any, errorOpts: object, done = (_resp: any): any => undefined) => {
+ httpClient.get(url).subscribe(
+ () => true,
+ (resp) => {
+ // Error must have been forwarded by the interceptor.
+ expect(resp instanceof HttpErrorResponse).toBeTruthy();
+ done(resp);
+ }
+ );
+ httpTesting.expectOne(url).error(error, errorOpts);
+ };
+
+ const runRouterTest = (errorOpts: object, expectedCallParams: any[]) => {
+ httpError(new ErrorEvent('abc'), errorOpts);
+ httpTesting.verify();
+ expect(router.navigate).toHaveBeenCalledWith(...expectedCallParams);
+ };
+
+ const runNotificationTest = (
+ error: any,
+ errorOpts: object,
+ expectedCallParams: CdNotification
+ ) => {
+ httpError(error, errorOpts);
+ httpTesting.verify();
+ expect(notificationService.show).toHaveBeenCalled();
+ expect(notificationService.save).toHaveBeenCalledWith(expectedCallParams);
+ };
+
+ const createCdNotification = (
+ type: NotificationType,
+ title?: string,
+ message?: string,
+ options?: any,
+ application?: string
+ ) => {
+ return new CdNotification(new CdNotificationConfig(type, title, message, options, application));
+ };
+
+ configureTestBed({
+ imports: [AppModule, HttpClientTestingModule],
+ providers: [
+ NotificationService,
+ {
+ provide: ToastrService,
+ useValue: {
+ error: () => true
+ }
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ const baseTime = new Date('2022-02-22');
+ spyOn(global, 'Date').and.returnValue(baseTime);
+
+ httpClient = TestBed.inject(HttpClient);
+ httpTesting = TestBed.inject(HttpTestingController);
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.callThrough();
+ spyOn(notificationService, 'save');
+
+ router = TestBed.inject(Router);
+ spyOn(router, 'navigate');
+ });
+
+ it('should be created', () => {
+ const service = TestBed.inject(ApiInterceptorService);
+ expect(service).toBeTruthy();
+ });
+
+ describe('test different error behaviours', () => {
+ beforeEach(() => {
+ spyOn(window, 'setTimeout').and.callFake((fn) => fn());
+ });
+
+ it('should redirect 401', () => {
+ runRouterTest(
+ {
+ status: 401
+ },
+ [['/login']]
+ );
+ });
+
+ it('should redirect 403', () => {
+ runRouterTest(
+ {
+ status: 403
+ },
+ [['error'], {'state': {'header': 'Access Denied', 'icon': 'fa fa-lock', 'message': 'Sorry, you don’t have permission to view this page or resource.', 'source': 'forbidden'}}] // prettier-ignore
+ );
+ });
+
+ it('should show notification (error string)', () => {
+ runNotificationTest(
+ 'foobar',
+ {
+ status: 500,
+ statusText: 'Foo Bar'
+ },
+ createCdNotification(0, '500 - Foo Bar', 'foobar')
+ );
+ });
+
+ it('should show notification (error object, triggered from backend)', () => {
+ runNotificationTest(
+ { detail: 'abc' },
+ {
+ status: 504,
+ statusText: 'AAA bbb CCC'
+ },
+ createCdNotification(0, '504 - AAA bbb CCC', 'abc')
+ );
+ });
+
+ it('should show notification (error object with unknown keys)', () => {
+ runNotificationTest(
+ { type: 'error' },
+ {
+ status: 0,
+ statusText: 'Unknown Error',
+ message: 'Http failure response for (unknown url): 0 Unknown Error',
+ name: 'HttpErrorResponse',
+ ok: false,
+ url: null
+ },
+ createCdNotification(
+ 0,
+ '0 - Unknown Error',
+ 'Http failure response for api/xyz: 0 Unknown Error'
+ )
+ );
+ });
+
+ it('should show notification (undefined error)', () => {
+ runNotificationTest(
+ undefined,
+ {
+ status: 502
+ },
+ createCdNotification(0, '502 - Unknown Error', 'Http failure response for api/xyz: 502 ')
+ );
+ });
+
+ it('should show 400 notification', () => {
+ spyOn(notificationService, 'notifyTask');
+ httpError({ task: { name: 'mytask', metadata: { component: 'foobar' } } }, { status: 400 });
+ httpTesting.verify();
+ expect(notificationService.show).toHaveBeenCalledTimes(0);
+ expect(notificationService.notifyTask).toHaveBeenCalledWith({
+ exception: { task: { metadata: { component: 'foobar' }, name: 'mytask' } },
+ metadata: { component: 'foobar' },
+ name: 'mytask',
+ success: false
+ });
+ });
+ });
+
+ describe('interceptor error handling', () => {
+ const expectSaveToHaveBeenCalled = (called: boolean) => {
+ tick(510);
+ if (called) {
+ expect(notificationService.save).toHaveBeenCalled();
+ } else {
+ expect(notificationService.save).not.toHaveBeenCalled();
+ }
+ };
+
+ it('should show default behaviour', fakeAsync(() => {
+ httpError(undefined, { status: 500 });
+ expectSaveToHaveBeenCalled(true);
+ }));
+
+ it('should prevent the default behaviour with preventDefault', fakeAsync(() => {
+ httpError(undefined, { status: 500 }, (resp) => resp.preventDefault());
+ expectSaveToHaveBeenCalled(false);
+ }));
+
+ it('should be able to use preventDefault with 400 errors', fakeAsync(() => {
+ httpError(
+ { task: { name: 'someName', metadata: { component: 'someComponent' } } },
+ { status: 400 },
+ (resp) => resp.preventDefault()
+ );
+ expectSaveToHaveBeenCalled(false);
+ }));
+
+ it('should prevent the default behaviour by status code', fakeAsync(() => {
+ httpError(undefined, { status: 500 }, (resp) => resp.ignoreStatusCode(500));
+ expectSaveToHaveBeenCalled(false);
+ }));
+
+ it('should use different application icon (default Ceph) in error message', fakeAsync(() => {
+ const msg = 'Cannot connect to Alertmanager';
+ httpError(undefined, { status: 500 }, (resp) => {
+ (resp.application = 'Prometheus'), (resp.message = msg);
+ });
+ expectSaveToHaveBeenCalled(true);
+ expect(notificationService.save).toHaveBeenCalledWith(
+ createCdNotification(0, '500 - Unknown Error', msg, undefined, 'Prometheus')
+ );
+ }));
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
new file mode 100644
index 000000000..fb7a9f733
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
@@ -0,0 +1,133 @@
+import {
+ HttpErrorResponse,
+ HttpEvent,
+ HttpHandler,
+ HttpInterceptor,
+ HttpRequest
+} from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { Router } from '@angular/router';
+
+import _ from 'lodash';
+import { Observable, throwError as observableThrowError } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { CdHelperClass } from '~/app/shared/classes/cd-helper.class';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { FinishedTask } from '../models/finished-task';
+import { AuthStorageService } from './auth-storage.service';
+import { NotificationService } from './notification.service';
+
+export class CdHttpErrorResponse extends HttpErrorResponse {
+ preventDefault: Function;
+ ignoreStatusCode: Function;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ApiInterceptorService implements HttpInterceptor {
+ constructor(
+ private router: Router,
+ private authStorageService: AuthStorageService,
+ public notificationService: NotificationService
+ ) {}
+
+ intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
+ const acceptHeader = request.headers.get('Accept');
+ let reqWithVersion: HttpRequest<any>;
+ if (acceptHeader && acceptHeader.startsWith('application/vnd.ceph.api.v')) {
+ reqWithVersion = request.clone();
+ } else {
+ reqWithVersion = request.clone({
+ setHeaders: {
+ Accept: CdHelperClass.cdVersionHeader('1', '0')
+ }
+ });
+ }
+ return next.handle(reqWithVersion).pipe(
+ catchError((resp: CdHttpErrorResponse) => {
+ if (resp instanceof HttpErrorResponse) {
+ let timeoutId: number;
+ switch (resp.status) {
+ case 400:
+ const finishedTask = new FinishedTask();
+
+ const task = resp.error.task;
+ if (_.isPlainObject(task)) {
+ task.metadata.component = task.metadata.component || resp.error.component;
+
+ finishedTask.name = task.name;
+ finishedTask.metadata = task.metadata;
+ } else {
+ finishedTask.metadata = resp.error;
+ }
+
+ finishedTask.success = false;
+ finishedTask.exception = resp.error;
+ timeoutId = this.notificationService.notifyTask(finishedTask);
+ break;
+ case 401:
+ this.authStorageService.remove();
+ this.router.navigate(['/login']);
+ break;
+ case 403:
+ this.router.navigate(['error'], {
+ state: {
+ message: $localize`Sorry, you don’t have permission to view this page or resource.`,
+ header: $localize`Access Denied`,
+ icon: 'fa fa-lock',
+ source: 'forbidden'
+ }
+ });
+ break;
+ default:
+ timeoutId = this.prepareNotification(resp);
+ }
+
+ /**
+ * Decorated preventDefault method (in case error previously had
+ * preventDefault method defined). If called, it will prevent a
+ * notification to be shown.
+ */
+ resp.preventDefault = () => {
+ this.notificationService.cancel(timeoutId);
+ };
+
+ /**
+ * If called, it will prevent a notification for the specific status code.
+ * @param {number} status The status code to be ignored.
+ */
+ resp.ignoreStatusCode = function (status: number) {
+ if (this.status === status) {
+ this.preventDefault();
+ }
+ };
+ }
+ // Return the error to the method that called it.
+ return observableThrowError(resp);
+ })
+ );
+ }
+
+ private prepareNotification(resp: any): number {
+ return this.notificationService.show(() => {
+ let message = '';
+ if (_.isPlainObject(resp.error) && _.isString(resp.error.detail)) {
+ message = resp.error.detail; // Error was triggered by the backend.
+ } else if (_.isString(resp.error)) {
+ message = resp.error;
+ } else if (_.isString(resp.message)) {
+ message = resp.message;
+ }
+ return new CdNotificationConfig(
+ NotificationType.error,
+ `${resp.status} - ${resp.statusText}`,
+ message,
+ undefined,
+ resp['application']
+ );
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts
new file mode 100644
index 000000000..22a6e8139
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts
@@ -0,0 +1,54 @@
+import { Component, NgZone } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AuthGuardService } from './auth-guard.service';
+import { AuthStorageService } from './auth-storage.service';
+
+describe('AuthGuardService', () => {
+ let service: AuthGuardService;
+ let authStorageService: AuthStorageService;
+ let ngZone: NgZone;
+ let route: ActivatedRouteSnapshot;
+ let state: RouterStateSnapshot;
+
+ @Component({ selector: 'cd-login', template: '' })
+ class LoginComponent {}
+
+ const routes: Routes = [{ path: 'login', component: LoginComponent }];
+
+ configureTestBed({
+ imports: [RouterTestingModule.withRoutes(routes)],
+ providers: [AuthGuardService, AuthStorageService],
+ declarations: [LoginComponent]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(AuthGuardService);
+ authStorageService = TestBed.inject(AuthStorageService);
+ ngZone = TestBed.inject(NgZone);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should allow the user if loggedIn', () => {
+ route = null;
+ state = { url: '/', root: null };
+ spyOn(authStorageService, 'isLoggedIn').and.returnValue(true);
+ expect(service.canActivate(route, state)).toBe(true);
+ });
+
+ it('should prevent user if not loggedIn and redirect to login page', fakeAsync(() => {
+ const router = TestBed.inject(Router);
+ state = { url: '/pool', root: null };
+ ngZone.run(() => {
+ expect(service.canActivate(route, state)).toBe(false);
+ });
+ tick();
+ expect(router.url).toBe('/login?returnUrl=%2Fpool');
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts
new file mode 100644
index 000000000..61c06c81d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core';
+import {
+ ActivatedRouteSnapshot,
+ CanActivate,
+ CanActivateChild,
+ Router,
+ RouterStateSnapshot
+} from '@angular/router';
+
+import { AuthStorageService } from './auth-storage.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AuthGuardService implements CanActivate, CanActivateChild {
+ constructor(private router: Router, private authStorageService: AuthStorageService) {}
+
+ canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
+ if (this.authStorageService.isLoggedIn()) {
+ return true;
+ }
+ this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
+ return false;
+ }
+
+ canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
+ return this.canActivate(childRoute, state);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts
new file mode 100644
index 000000000..f202c095f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts
@@ -0,0 +1,47 @@
+import { AuthStorageService } from './auth-storage.service';
+
+describe('AuthStorageService', () => {
+ let service: AuthStorageService;
+ const username = 'foobar';
+
+ beforeEach(() => {
+ service = new AuthStorageService();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should store username', () => {
+ service.set(username, '');
+ expect(localStorage.getItem('dashboard_username')).toBe(username);
+ });
+
+ it('should remove username', () => {
+ service.set(username, '');
+ service.remove();
+ expect(localStorage.getItem('dashboard_username')).toBe(null);
+ });
+
+ it('should be loggedIn', () => {
+ service.set(username, '');
+ expect(service.isLoggedIn()).toBe(true);
+ });
+
+ it('should not be loggedIn', () => {
+ service.remove();
+ expect(service.isLoggedIn()).toBe(false);
+ });
+
+ it('should be SSO', () => {
+ service.set(username, {}, true);
+ expect(localStorage.getItem('sso')).toBe('true');
+ expect(service.isSSO()).toBe(true);
+ });
+
+ it('should not be SSO', () => {
+ service.set(username);
+ expect(localStorage.getItem('sso')).toBe('false');
+ expect(service.isSSO()).toBe(false);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts
new file mode 100644
index 000000000..15e21f9ed
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts
@@ -0,0 +1,59 @@
+import { Injectable } from '@angular/core';
+
+import { BehaviorSubject } from 'rxjs';
+
+import { Permissions } from '../models/permissions';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AuthStorageService {
+ isPwdDisplayedSource = new BehaviorSubject(false);
+ isPwdDisplayed$ = this.isPwdDisplayedSource.asObservable();
+
+ set(
+ username: string,
+ permissions = {},
+ sso = false,
+ pwdExpirationDate: number = null,
+ pwdUpdateRequired: boolean = false
+ ) {
+ localStorage.setItem('dashboard_username', username);
+ localStorage.setItem('dashboard_permissions', JSON.stringify(new Permissions(permissions)));
+ localStorage.setItem('user_pwd_expiration_date', String(pwdExpirationDate));
+ localStorage.setItem('user_pwd_update_required', String(pwdUpdateRequired));
+ localStorage.setItem('sso', String(sso));
+ }
+
+ remove() {
+ localStorage.removeItem('dashboard_username');
+ localStorage.removeItem('user_pwd_expiration_data');
+ localStorage.removeItem('user_pwd_update_required');
+ }
+
+ isLoggedIn() {
+ return localStorage.getItem('dashboard_username') !== null;
+ }
+
+ getUsername() {
+ return localStorage.getItem('dashboard_username');
+ }
+
+ getPermissions(): Permissions {
+ return JSON.parse(
+ localStorage.getItem('dashboard_permissions') || JSON.stringify(new Permissions({}))
+ );
+ }
+
+ getPwdExpirationDate(): number {
+ return Number(localStorage.getItem('user_pwd_expiration_date'));
+ }
+
+ getPwdUpdateRequired(): boolean {
+ return localStorage.getItem('user_pwd_update_required') === 'true';
+ }
+
+ isSSO() {
+ return localStorage.getItem('sso') === 'true';
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts
new file mode 100644
index 000000000..dbe7bb452
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { CdTableServerSideService } from './cd-table-server-side.service';
+
+describe('CdTableServerSideService', () => {
+ let service: CdTableServerSideService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CdTableServerSideService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts
new file mode 100644
index 000000000..56bf807a6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts
@@ -0,0 +1,14 @@
+import { HttpResponse } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CdTableServerSideService {
+ /* tslint:disable:no-empty */
+ constructor() {}
+
+ static getCount(resp: HttpResponse<any>): number {
+ return Number(resp.headers?.get('X-Total-Count'));
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts
new file mode 100644
index 000000000..12800d112
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts
@@ -0,0 +1,68 @@
+import { Component, NgZone } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AuthStorageService } from './auth-storage.service';
+import { ChangePasswordGuardService } from './change-password-guard.service';
+
+describe('ChangePasswordGuardService', () => {
+ let service: ChangePasswordGuardService;
+ let authStorageService: AuthStorageService;
+ let ngZone: NgZone;
+ let route: ActivatedRouteSnapshot;
+ let state: RouterStateSnapshot;
+
+ @Component({ selector: 'cd-login-password-form', template: '' })
+ class LoginPasswordFormComponent {}
+
+ const routes: Routes = [{ path: 'login-change-password', component: LoginPasswordFormComponent }];
+
+ configureTestBed({
+ imports: [RouterTestingModule.withRoutes(routes)],
+ providers: [ChangePasswordGuardService, AuthStorageService],
+ declarations: [LoginPasswordFormComponent]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(ChangePasswordGuardService);
+ authStorageService = TestBed.inject(AuthStorageService);
+ ngZone = TestBed.inject(NgZone);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should do nothing (not logged in)', () => {
+ spyOn(authStorageService, 'isLoggedIn').and.returnValue(false);
+ expect(service.canActivate(route, state)).toBeTruthy();
+ });
+
+ it('should do nothing (SSO enabled)', () => {
+ spyOn(authStorageService, 'isLoggedIn').and.returnValue(true);
+ spyOn(authStorageService, 'isSSO').and.returnValue(true);
+ expect(service.canActivate(route, state)).toBeTruthy();
+ });
+
+ it('should do nothing (no update pwd required)', () => {
+ spyOn(authStorageService, 'isLoggedIn').and.returnValue(true);
+ spyOn(authStorageService, 'getPwdUpdateRequired').and.returnValue(false);
+ expect(service.canActivate(route, state)).toBeTruthy();
+ });
+
+ it('should redirect to change password page by preserving the query params', fakeAsync(() => {
+ route = null;
+ state = { url: '/host', root: null };
+ spyOn(authStorageService, 'isLoggedIn').and.returnValue(true);
+ spyOn(authStorageService, 'isSSO').and.returnValue(false);
+ spyOn(authStorageService, 'getPwdUpdateRequired').and.returnValue(true);
+ const router = TestBed.inject(Router);
+ ngZone.run(() => {
+ expect(service.canActivate(route, state)).toBeFalsy();
+ });
+ tick();
+ expect(router.url).toBe('/login-change-password?returnUrl=%2Fhost');
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts
new file mode 100644
index 000000000..d97160f92
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts
@@ -0,0 +1,42 @@
+import { Injectable } from '@angular/core';
+import {
+ ActivatedRouteSnapshot,
+ CanActivate,
+ CanActivateChild,
+ Router,
+ RouterStateSnapshot
+} from '@angular/router';
+
+import { AuthStorageService } from './auth-storage.service';
+
+/**
+ * This service guard checks if a user must be redirected to a special
+ * page at '/login-change-password' to set a new password.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class ChangePasswordGuardService implements CanActivate, CanActivateChild {
+ constructor(private router: Router, private authStorageService: AuthStorageService) {}
+
+ canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
+ // Redirect to '/login-change-password' when the following constraints
+ // are fulfilled:
+ // - The user must be logged in.
+ // - SSO must be disabled.
+ // - The flag 'User must change password at next logon' must be set.
+ if (
+ this.authStorageService.isLoggedIn() &&
+ !this.authStorageService.isSSO() &&
+ this.authStorageService.getPwdUpdateRequired()
+ ) {
+ this.router.navigate(['/login-change-password'], { queryParams: { returnUrl: state.url } });
+ return false;
+ }
+ return true;
+ }
+
+ canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
+ return this.canActivate(childRoute, state);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts
new file mode 100644
index 000000000..00524317e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts
@@ -0,0 +1,92 @@
+import { TestBed } from '@angular/core/testing';
+
+import moment from 'moment';
+
+import { CdDevice } from '../models/devices';
+import { DeviceService } from './device.service';
+
+describe('DeviceService', () => {
+ let service: DeviceService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(DeviceService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('should test getDevices pipe', () => {
+ let now: jasmine.Spy = null;
+
+ const newDevice = (data: object): CdDevice => {
+ const device: CdDevice = {
+ devid: '',
+ location: [{ host: '', dev: '' }],
+ daemons: []
+ };
+ Object.assign(device, data);
+ return device;
+ };
+
+ beforeEach(() => {
+ // Mock 'moment.now()' to simplify testing by enabling testing with fixed dates.
+ now = spyOn(moment, 'now').and.returnValue(
+ moment('2019-10-01T00:00:00.00000+0100').valueOf()
+ );
+ });
+
+ afterEach(() => {
+ expect(now).toHaveBeenCalled();
+ });
+
+ it('should return status "good" for life expectancy > 6 weeks', () => {
+ const preparedDevice = service.calculateAdditionalData(
+ newDevice({
+ life_expectancy_min: '2019-11-14T01:00:00.000000+0100',
+ life_expectancy_max: '0.000000',
+ life_expectancy_stamp: '2019-10-01T02:08:48.627312+0100'
+ })
+ );
+ expect(preparedDevice.life_expectancy_weeks).toEqual({ max: null, min: 6 });
+ expect(preparedDevice.state).toBe('good');
+ });
+
+ it('should return status "warning" for life expectancy <= 4 weeks', () => {
+ const preparedDevice = service.calculateAdditionalData(
+ newDevice({
+ life_expectancy_min: '2019-10-14T01:00:00.000000+0100',
+ life_expectancy_max: '2019-11-14T01:00:00.000000+0100',
+ life_expectancy_stamp: '2019-10-01T00:00:00.00000+0100'
+ })
+ );
+ expect(preparedDevice.life_expectancy_weeks).toEqual({ max: 6, min: 2 });
+ expect(preparedDevice.state).toBe('warning');
+ });
+
+ it('should return status "bad" for life expectancy <= 2 weeks', () => {
+ const preparedDevice = service.calculateAdditionalData(
+ newDevice({
+ life_expectancy_min: '0.000000',
+ life_expectancy_max: '2019-10-12T01:00:00.000000+0100',
+ life_expectancy_stamp: '2019-10-01T00:00:00.00000+0100'
+ })
+ );
+ expect(preparedDevice.life_expectancy_weeks).toEqual({ max: 2, min: null });
+ expect(preparedDevice.state).toBe('bad');
+ });
+
+ it('should return status "stale" for time stamp that is older than a week', () => {
+ const preparedDevice = service.calculateAdditionalData(
+ newDevice({
+ life_expectancy_min: '0.000000',
+ life_expectancy_max: '0.000000',
+ life_expectancy_stamp: '2019-09-21T00:00:00.00000+0100'
+ })
+ );
+ expect(preparedDevice.life_expectancy_weeks).toEqual({ max: null, min: null });
+ expect(preparedDevice.state).toBe('stale');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts
new file mode 100644
index 000000000..b433f235b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts
@@ -0,0 +1,57 @@
+import { Injectable } from '@angular/core';
+
+import moment from 'moment';
+
+import { CdDevice } from '../models/devices';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DeviceService {
+ /**
+ * Calculates additional data and appends them as new attributes to the given device.
+ */
+ calculateAdditionalData(device: CdDevice): CdDevice {
+ if (!device.life_expectancy_min || !device.life_expectancy_max) {
+ device.state = 'unknown';
+ return device;
+ }
+ const hasDate = (float: string): boolean => !!Number.parseFloat(float);
+ const weeks = (isoDate1: string, isoDate2: string): number =>
+ !isoDate1 || !isoDate2 || !hasDate(isoDate1) || !hasDate(isoDate2)
+ ? null
+ : moment.duration(moment(isoDate1).diff(moment(isoDate2))).asWeeks();
+
+ const ageOfStamp = moment
+ .duration(moment(moment.now()).diff(moment(device.life_expectancy_stamp)))
+ .asWeeks();
+ const max = weeks(device.life_expectancy_max, device.life_expectancy_stamp);
+ const min = weeks(device.life_expectancy_min, device.life_expectancy_stamp);
+
+ if (ageOfStamp > 1) {
+ device.state = 'stale';
+ } else if (max !== null && max <= 2) {
+ device.state = 'bad';
+ } else if (min !== null && min <= 4) {
+ device.state = 'warning';
+ } else {
+ device.state = 'good';
+ }
+
+ device.life_expectancy_weeks = {
+ max: max !== null ? Math.round(max) : null,
+ min: min !== null ? Math.round(min) : null
+ };
+
+ return device;
+ }
+
+ readable(device: CdDevice): CdDevice {
+ device.readableDaemons = device.daemons.join(' ');
+ return device;
+ }
+
+ prepareDevice(device: CdDevice): CdDevice {
+ return this.readable(this.calculateAdditionalData(device));
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts
new file mode 100644
index 000000000..7c3bf24dd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts
@@ -0,0 +1,75 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { Subscriber } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SharedModule } from '../shared.module';
+import { DocService } from './doc.service';
+
+describe('DocService', () => {
+ let service: DocService;
+
+ configureTestBed({ imports: [HttpClientTestingModule, SharedModule] });
+
+ beforeEach(() => {
+ service = TestBed.inject(DocService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should return full URL', () => {
+ expect(service.urlGenerator('iscsi', 'foo')).toBe(
+ 'https://docs.ceph.com/en/foo/mgr/dashboard/#enabling-iscsi-management'
+ );
+ });
+
+ it('should return latest version URL for master', () => {
+ expect(service.urlGenerator('orch', 'master')).toBe(
+ 'https://docs.ceph.com/en/latest/mgr/orchestrator'
+ );
+ });
+
+ describe('Name of the group', () => {
+ let result: string;
+ let i: number;
+
+ const nextSummary = (newData: any) => service['releaseDataSource'].next(newData);
+
+ const callback = (response: string) => {
+ i++;
+ result = response;
+ };
+
+ beforeEach(() => {
+ i = 0;
+ result = undefined;
+ nextSummary(undefined);
+ });
+
+ it('should call subscribeOnce without releaseName', () => {
+ const subscriber = service.subscribeOnce('prometheus', callback);
+
+ expect(subscriber).toEqual(jasmine.any(Subscriber));
+ expect(i).toBe(0);
+ expect(result).toEqual(undefined);
+ });
+
+ it('should call subscribeOnce with releaseName', () => {
+ const subscriber = service.subscribeOnce('prometheus', callback);
+
+ expect(subscriber).toEqual(jasmine.any(Subscriber));
+ expect(i).toBe(0);
+ expect(result).toEqual(undefined);
+
+ nextSummary('foo');
+ expect(result).toEqual(
+ 'https://docs.ceph.com/en/foo/mgr/dashboard/#enabling-prometheus-alerting'
+ );
+ expect(i).toBe(1);
+ expect(subscriber.closed).toBe(true);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts
new file mode 100644
index 000000000..4cbb4cf18
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts
@@ -0,0 +1,65 @@
+import { Injectable } from '@angular/core';
+
+import { BehaviorSubject, Subscription } from 'rxjs';
+import { filter, first, map } from 'rxjs/operators';
+
+import { CephReleaseNamePipe } from '../pipes/ceph-release-name.pipe';
+import { SummaryService } from './summary.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DocService {
+ private releaseDataSource = new BehaviorSubject<string>(null);
+ releaseData$ = this.releaseDataSource.asObservable();
+
+ constructor(
+ private summaryservice: SummaryService,
+ private cephReleaseNamePipe: CephReleaseNamePipe
+ ) {
+ this.summaryservice.subscribeOnce((summary) => {
+ const releaseName = this.cephReleaseNamePipe.transform(summary.version);
+ this.releaseDataSource.next(releaseName);
+ });
+ }
+
+ urlGenerator(section: string, release = 'master'): string {
+ const docVersion = release === 'master' ? 'latest' : release;
+ const domain = `https://docs.ceph.com/en/${docVersion}/`;
+ const domainCeph = `https://ceph.io/`;
+
+ const sections = {
+ iscsi: `${domain}mgr/dashboard/#enabling-iscsi-management`,
+ prometheus: `${domain}mgr/dashboard/#enabling-prometheus-alerting`,
+ 'nfs-ganesha': `${domain}mgr/dashboard/#configuring-nfs-ganesha-in-the-dashboard`,
+ 'rgw-nfs': `${domain}radosgw/nfs`,
+ rgw: `${domain}mgr/dashboard/#enabling-the-object-gateway-management-frontend`,
+ dashboard: `${domain}mgr/dashboard`,
+ grafana: `${domain}mgr/dashboard/#enabling-the-embedding-of-grafana-dashboards`,
+ orch: `${domain}mgr/orchestrator`,
+ pgs: `${domainCeph}pgcalc`,
+ help: `${domainCeph}help/`,
+ security: `${domainCeph}security/`,
+ trademarks: `${domainCeph}legal-page/trademarks/`,
+ 'dashboard-landing-page-status': `${domain}mgr/dashboard/#dashboard-landing-page-status`,
+ 'dashboard-landing-page-performance': `${domain}mgr/dashboard/#dashboard-landing-page-performance`,
+ 'dashboard-landing-page-capacity': `${domain}mgr/dashboard/#dashboard-landing-page-capacity`
+ };
+
+ return sections[section];
+ }
+
+ subscribeOnce(
+ section: string,
+ next: (release: string) => void,
+ error?: (error: any) => void
+ ): Subscription {
+ return this.releaseData$
+ .pipe(
+ filter((value) => !!value),
+ map((release) => this.urlGenerator(section, release)),
+ first()
+ )
+ .subscribe(next, error);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts
new file mode 100644
index 000000000..0c9e619ea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts
@@ -0,0 +1,23 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FaviconService } from './favicon.service';
+
+describe('FaviconService', () => {
+ let service: FaviconService;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [FaviconService, CssHelper]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(FaviconService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts
new file mode 100644
index 000000000..87ce8fcad
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts
@@ -0,0 +1,79 @@
+import { DOCUMENT } from '@angular/common';
+import { Inject, Injectable, OnDestroy } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { HealthColor } from '~/app/shared/enum/health-color.enum';
+import { SummaryService } from './summary.service';
+
+@Injectable()
+export class FaviconService implements OnDestroy {
+ sub: Subscription;
+ oldStatus: string;
+ url: string;
+
+ constructor(
+ @Inject(DOCUMENT) private document: HTMLDocument,
+ private summaryService: SummaryService,
+ private cssHelper: CssHelper
+ ) {}
+
+ init() {
+ this.url = this.document.getElementById('cdFavicon')?.getAttribute('href');
+
+ this.sub = this.summaryService.subscribe((summary) => {
+ this.changeIcon(summary.health_status);
+ });
+ }
+
+ changeIcon(status?: string) {
+ if (status === this.oldStatus) {
+ return;
+ }
+
+ this.oldStatus = status;
+
+ const favicon = this.document.getElementById('cdFavicon');
+ const faviconSize = 16;
+ const radius = faviconSize / 4;
+
+ const canvas = this.document.createElement('canvas');
+ canvas.width = faviconSize;
+ canvas.height = faviconSize;
+
+ const context = canvas.getContext('2d');
+ const img = this.document.createElement('img');
+ img.src = this.url;
+
+ img.onload = () => {
+ // Draw Original Favicon as Background
+ context.drawImage(img, 0, 0, faviconSize, faviconSize);
+
+ if (Object.keys(HealthColor).includes(status as HealthColor)) {
+ // Cut notification circle area
+ context.save();
+ context.globalCompositeOperation = 'destination-out';
+ context.beginPath();
+ context.arc(canvas.width - radius, radius, radius + 2, 0, 2 * Math.PI);
+ context.fill();
+ context.restore();
+
+ // Draw Notification Circle
+ context.beginPath();
+ context.arc(canvas.width - radius, radius, radius, 0, 2 * Math.PI);
+
+ context.fillStyle = this.cssHelper.propertyValue(HealthColor[status]);
+ context.fill();
+ }
+
+ // Replace favicon
+ favicon.setAttribute('href', canvas.toDataURL('image/png'));
+ };
+ }
+
+ ngOnDestroy() {
+ this.changeIcon();
+ this.sub?.unsubscribe();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts
new file mode 100644
index 000000000..883139986
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts
@@ -0,0 +1,72 @@
+import { Component, NgZone } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of as observableOf } from 'rxjs';
+
+import { DashboardNotFoundError } from '~/app/core/error/error';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FeatureTogglesGuardService } from './feature-toggles-guard.service';
+import { FeatureTogglesService } from './feature-toggles.service';
+
+describe('FeatureTogglesGuardService', () => {
+ let service: FeatureTogglesGuardService;
+ let fakeFeatureTogglesService: FeatureTogglesService;
+ let router: Router;
+ let ngZone: NgZone;
+
+ @Component({ selector: 'cd-cephfs', template: '' })
+ class CephfsComponent {}
+
+ @Component({ selector: 'cd-404', template: '' })
+ class NotFoundComponent {}
+
+ const routes: Routes = [
+ { path: 'cephfs', component: CephfsComponent },
+ { path: '404', component: NotFoundComponent }
+ ];
+
+ configureTestBed({
+ imports: [RouterTestingModule.withRoutes(routes)],
+ providers: [
+ { provide: FeatureTogglesService, useValue: { get: null } },
+ FeatureTogglesGuardService
+ ],
+ declarations: [CephfsComponent, NotFoundComponent]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(FeatureTogglesGuardService);
+ fakeFeatureTogglesService = TestBed.inject(FeatureTogglesService);
+ ngZone = TestBed.inject(NgZone);
+ router = TestBed.inject(Router);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ function testCanActivate(path: string, feature_toggles_map: object) {
+ let result: boolean;
+ spyOn(fakeFeatureTogglesService, 'get').and.returnValue(observableOf(feature_toggles_map));
+
+ ngZone.run(() => {
+ service
+ .canActivate(<ActivatedRouteSnapshot>{ routeConfig: { path: path } })
+ .subscribe((val) => (result = val));
+ });
+ tick();
+
+ return result;
+ }
+
+ it('should allow the feature if enabled', fakeAsync(() => {
+ expect(testCanActivate('cephfs', { cephfs: true })).toBe(true);
+ expect(router.url).toBe('/');
+ }));
+
+ it('should throw error if disable', fakeAsync(() => {
+ expect(() => testCanActivate('cephfs', { cephfs: false })).toThrowError(DashboardNotFoundError);
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts
new file mode 100644
index 000000000..ad94f2689
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, CanActivate, CanActivateChild } from '@angular/router';
+
+import { map } from 'rxjs/operators';
+
+import { DashboardNotFoundError } from '~/app/core/error/error';
+import { FeatureTogglesMap, FeatureTogglesService } from './feature-toggles.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class FeatureTogglesGuardService implements CanActivate, CanActivateChild {
+ constructor(private featureToggles: FeatureTogglesService) {}
+
+ canActivate(route: ActivatedRouteSnapshot) {
+ return this.featureToggles.get().pipe(
+ map((enabledFeatures: FeatureTogglesMap) => {
+ if (enabledFeatures[route.routeConfig.path] === false) {
+ throw new DashboardNotFoundError();
+ return false;
+ }
+ return true;
+ })
+ );
+ }
+
+ canActivateChild(route: ActivatedRouteSnapshot) {
+ return this.canActivate(route.parent);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts
new file mode 100644
index 000000000..ddb888851
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts
@@ -0,0 +1,54 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FeatureTogglesService } from './feature-toggles.service';
+
+describe('FeatureTogglesService', () => {
+ let httpTesting: HttpTestingController;
+ let service: FeatureTogglesService;
+
+ configureTestBed({
+ providers: [FeatureTogglesService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(FeatureTogglesService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should fetch HTTP endpoint once and only once', fakeAsync(() => {
+ const mockFeatureTogglesMap = [
+ {
+ rbd: true,
+ mirroring: true,
+ iscsi: true,
+ cephfs: true,
+ rgw: true
+ }
+ ];
+
+ service
+ .get()
+ .subscribe((featureTogglesMap) => expect(featureTogglesMap).toEqual(mockFeatureTogglesMap));
+ tick();
+
+ // Second subscription shouldn't trigger a new HTTP request
+ service
+ .get()
+ .subscribe((featureTogglesMap) => expect(featureTogglesMap).toEqual(mockFeatureTogglesMap));
+
+ const req = httpTesting.expectOne(service.API_URL);
+ req.flush(mockFeatureTogglesMap);
+ discardPeriodicTasks();
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts
new file mode 100644
index 000000000..bb7f2a0d6
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts
@@ -0,0 +1,37 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+import { TimerService } from './timer.service';
+
+export class FeatureTogglesMap {
+ rbd = true;
+ mirroring = true;
+ iscsi = true;
+ cephfs = true;
+ rgw = true;
+ nfs = true;
+}
+export type Features = keyof FeatureTogglesMap;
+export type FeatureTogglesMap$ = Observable<FeatureTogglesMap>;
+
+@Injectable({
+ providedIn: 'root'
+})
+export class FeatureTogglesService {
+ readonly API_URL: string = 'api/feature_toggles';
+ readonly REFRESH_INTERVAL: number = 30000;
+ private featureToggleMap$: FeatureTogglesMap$;
+
+ constructor(private http: HttpClient, private timerService: TimerService) {
+ this.featureToggleMap$ = this.timerService.get(
+ () => this.http.get<FeatureTogglesMap>(this.API_URL),
+ this.REFRESH_INTERVAL
+ );
+ }
+
+ get(): FeatureTogglesMap$ {
+ return this.featureToggleMap$;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts
new file mode 100644
index 000000000..359c6028a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts
@@ -0,0 +1,90 @@
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DimlessBinaryPipe } from '../pipes/dimless-binary.pipe';
+import { DimlessPipe } from '../pipes/dimless.pipe';
+import { FormatterService } from './formatter.service';
+
+describe('FormatterService', () => {
+ let service: FormatterService;
+ let dimlessBinaryPipe: DimlessBinaryPipe;
+ let dimlessPipe: DimlessPipe;
+
+ const convertToBytesAndBack = (value: string, newValue?: string) => {
+ expect(dimlessBinaryPipe.transform(service.toBytes(value))).toBe(newValue || value);
+ };
+
+ configureTestBed({
+ providers: [FormatterService, DimlessBinaryPipe]
+ });
+
+ beforeEach(() => {
+ service = new FormatterService();
+ dimlessBinaryPipe = new DimlessBinaryPipe(service);
+ dimlessPipe = new DimlessPipe(service);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('format_number', () => {
+ const formats = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+
+ it('should return minus for unsupported values', () => {
+ expect(service.format_number(service, 1024, formats)).toBe('-');
+ expect(service.format_number(undefined, 1024, formats)).toBe('-');
+ expect(service.format_number(null, 1024, formats)).toBe('-');
+ });
+
+ it('should test some values', () => {
+ expect(service.format_number('0', 1024, formats)).toBe('0 B');
+ expect(service.format_number('0.1', 1024, formats)).toBe('0.1 B');
+ expect(service.format_number('1.2', 1024, formats)).toBe('1.2 B');
+ expect(service.format_number('1', 1024, formats)).toBe('1 B');
+ expect(service.format_number('1024', 1024, formats)).toBe('1 KiB');
+ expect(service.format_number(23.45678 * Math.pow(1024, 3), 1024, formats)).toBe('23.5 GiB');
+ expect(service.format_number(23.45678 * Math.pow(1024, 3), 1024, formats, 2)).toBe(
+ '23.46 GiB'
+ );
+ });
+
+ it('should test some dimless values', () => {
+ expect(dimlessPipe.transform(0.6)).toBe('0.6');
+ expect(dimlessPipe.transform(1000.608)).toBe('1 k');
+ expect(dimlessPipe.transform(1e10)).toBe('10 G');
+ expect(dimlessPipe.transform(2.37e16)).toBe('23.7 P');
+ });
+ });
+
+ describe('toBytes', () => {
+ it('should not convert wrong values', () => {
+ expect(service.toBytes('10xyz')).toBeNull();
+ expect(service.toBytes('1.1.1KiB')).toBeNull();
+ expect(service.toBytes('1.1 KiloByte')).toBeNull();
+ expect(service.toBytes('1.1 kib')).toBeNull();
+ expect(service.toBytes('1.kib')).toBeNull();
+ expect(service.toBytes('1 ki')).toBeNull();
+ expect(service.toBytes(undefined)).toBeNull();
+ expect(service.toBytes('')).toBeNull();
+ expect(service.toBytes('-')).toBeNull();
+ expect(service.toBytes(null)).toBeNull();
+ });
+
+ it('should convert values to bytes', () => {
+ expect(service.toBytes('4815162342')).toBe(4815162342);
+ expect(service.toBytes('100M')).toBe(104857600);
+ expect(service.toBytes('100 M')).toBe(104857600);
+ expect(service.toBytes('100 mIb')).toBe(104857600);
+ expect(service.toBytes('100 mb')).toBe(104857600);
+ expect(service.toBytes('100MIB')).toBe(104857600);
+ expect(service.toBytes('1.532KiB')).toBe(Math.round(1.532 * 1024));
+ expect(service.toBytes('0.000000000001TiB')).toBe(1);
+ });
+
+ it('should convert values to human readable again', () => {
+ convertToBytesAndBack('1.1 MiB');
+ convertToBytesAndBack('1.0MiB', '1 MiB');
+ convertToBytesAndBack('8.9 GiB');
+ convertToBytesAndBack('123.5 EiB');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts
new file mode 100644
index 000000000..a4b6d427b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts
@@ -0,0 +1,77 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class FormatterService {
+ format_number(n: any, divisor: number, units: string[], decimals: number = 1): string {
+ if (_.isString(n)) {
+ n = Number(n);
+ }
+ if (!_.isNumber(n)) {
+ return '-';
+ }
+ let unit = n < 1 ? 0 : Math.floor(Math.log(n) / Math.log(divisor));
+ unit = unit >= units.length ? units.length - 1 : unit;
+ let result = _.round(n / Math.pow(divisor, unit), decimals).toString();
+ if (result === '') {
+ return '-';
+ }
+ if (units[unit] !== '') {
+ result = `${result} ${units[unit]}`;
+ }
+ return result;
+ }
+
+ /**
+ * Convert the given value into bytes.
+ * @param {string} value The value to be converted, e.g. 1024B, 10M, 300KiB or 1ZB.
+ * @param error_value The value returned in case the regular expression did not match. Defaults to
+ * null.
+ * @returns Returns the given value in bytes without any unit appended or the defined error value
+ * in case xof an error.
+ */
+ toBytes(value: string, error_value: number = null): number | null {
+ const base = 1024;
+ const units = ['b', 'k', 'm', 'g', 't', 'p', 'e', 'z', 'y'];
+ const m = RegExp('^(\\d+(.\\d+)?) ?([' + units.join('') + ']?(b|ib|B/s)?)?$', 'i').exec(value);
+ if (m === null) {
+ return error_value;
+ }
+ let bytes = parseFloat(m[1]);
+ if (_.isString(m[3])) {
+ bytes = bytes * Math.pow(base, units.indexOf(m[3].toLowerCase()[0]));
+ }
+ return Math.round(bytes);
+ }
+
+ /**
+ * Converts `x ms` to `x` (currently) or `0` if the conversion fails
+ */
+ toMilliseconds(value: string): number {
+ const pattern = /^\s*(\d+)\s*(ms)?\s*$/i;
+ const testResult = pattern.exec(value);
+
+ if (testResult !== null) {
+ return +testResult[1];
+ }
+
+ return 0;
+ }
+
+ /**
+ * Converts `x IOPS` to `x` (currently) or `0` if the conversion fails
+ */
+ toIops(value: string): number {
+ const pattern = /^\s*(\d+)\s*(IOPS)?\s*$/i;
+ const testResult = pattern.exec(value);
+
+ if (testResult !== null) {
+ return +testResult[1];
+ }
+
+ return 0;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts
new file mode 100644
index 000000000..de42d005e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts
@@ -0,0 +1,33 @@
+import { ErrorHandler, Injectable, Injector } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { DashboardError } from '~/app/core/error/error';
+import { LoggingService } from '../api/logging.service';
+
+@Injectable()
+export class JsErrorHandler implements ErrorHandler {
+ constructor(private injector: Injector, private router: Router) {}
+
+ handleError(error: any) {
+ const loggingService = this.injector.get(LoggingService);
+ const url = window.location.href;
+ const message = error && error.message;
+ const stack = error && error.stack;
+ loggingService.jsError(url, message, stack).subscribe();
+ if (error.rejection instanceof DashboardError) {
+ setTimeout(
+ () =>
+ this.router.navigate(['error'], {
+ state: {
+ message: error.rejection.message,
+ header: error.rejection.header,
+ icon: error.rejection.icon
+ }
+ }),
+ 50
+ );
+ } else {
+ throw error;
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.spec.ts
new file mode 100644
index 000000000..dacff44f0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.spec.ts
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { LanguageService } from './language.service';
+
+describe('LanguageService', () => {
+ let service: LanguageService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ providers: [LanguageService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(LanguageService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call create', () => {
+ service.getLanguages().subscribe();
+ const req = httpTesting.expectOne('ui-api/langs');
+ expect(req.request.method).toBe('GET');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts
new file mode 100644
index 000000000..d2705ee36
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts
@@ -0,0 +1,23 @@
+import { HttpClient } from '@angular/common/http';
+import { Inject, Injectable, LOCALE_ID } from '@angular/core';
+
+import { environment } from '~/environments/environment';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LanguageService {
+ constructor(private http: HttpClient, @Inject(LOCALE_ID) protected localeId: string) {}
+
+ getLocale(): string {
+ return this.localeId || environment.default_lang;
+ }
+
+ setLocale(lang: string) {
+ document.cookie = `cd-lang=${lang}`;
+ }
+
+ getLanguages() {
+ return this.http.get<string[]>('ui-api/langs');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts
new file mode 100644
index 000000000..4e5ed061d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts
@@ -0,0 +1,59 @@
+import { Component } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { NgbActiveModal, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ModalService } from './modal.service';
+
+@Component({
+ template: ``
+})
+class MockComponent {
+ foo = '';
+
+ constructor(public activeModal: NgbActiveModal) {}
+}
+
+describe('ModalService', () => {
+ let service: ModalService;
+ let ngbModal: NgbModal;
+
+ configureTestBed({ declarations: [MockComponent], imports: [NgbModalModule] }, [MockComponent]);
+
+ beforeEach(() => {
+ service = TestBed.inject(ModalService);
+ ngbModal = TestBed.inject(NgbModal);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call NgbModal.open when show is called', () => {
+ spyOn(ngbModal, 'open').and.callThrough();
+
+ const modaRef = service.show(MockComponent, { foo: 'bar' });
+
+ expect(ngbModal.open).toBeCalled();
+ expect(modaRef.componentInstance.foo).toBe('bar');
+ expect(modaRef.componentInstance.activeModal).toBeTruthy();
+ });
+
+ it('should call dismissAll and hasOpenModals', fakeAsync(() => {
+ spyOn(ngbModal, 'dismissAll').and.callThrough();
+ spyOn(ngbModal, 'hasOpenModals').and.callThrough();
+
+ expect(ngbModal.hasOpenModals()).toBeFalsy();
+
+ service.show(MockComponent, { foo: 'bar' });
+ expect(service.hasOpenModals()).toBeTruthy();
+
+ service.dismissAll();
+ tick();
+ expect(service.hasOpenModals()).toBeFalsy();
+
+ expect(ngbModal.dismissAll).toBeCalled();
+ expect(ngbModal.hasOpenModals).toBeCalled();
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts
new file mode 100644
index 000000000..33ce8bd4d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@angular/core';
+
+import { NgbModal, NgbModalOptions, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ModalService {
+ constructor(private modal: NgbModal) {}
+
+ show(component: any, initialState?: any, options?: NgbModalOptions): NgbModalRef {
+ const modalRef = this.modal.open(component, options);
+
+ if (initialState) {
+ Object.assign(modalRef.componentInstance, initialState);
+ }
+
+ return modalRef;
+ }
+
+ dismissAll() {
+ this.modal.dismissAll();
+ }
+
+ hasOpenModals() {
+ return this.modal.hasOpenModals();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts
new file mode 100644
index 000000000..532aa6c65
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts
@@ -0,0 +1,102 @@
+import { HttpClient } from '@angular/common/http';
+import { Component, NgZone } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of as observableOf, throwError } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrModuleService } from '../api/mgr-module.service';
+import { ModuleStatusGuardService } from './module-status-guard.service';
+
+describe('ModuleStatusGuardService', () => {
+ let service: ModuleStatusGuardService;
+ let httpClient: HttpClient;
+ let router: Router;
+ let route: ActivatedRouteSnapshot;
+ let ngZone: NgZone;
+ let mgrModuleService: MgrModuleService;
+
+ @Component({ selector: 'cd-foo', template: '' })
+ class FooComponent {}
+
+ const fakeService = {
+ get: () => true
+ };
+
+ const routes: Routes = [{ path: '**', component: FooComponent }];
+
+ const testCanActivate = (
+ getResult: {},
+ activateResult: boolean,
+ urlResult: string,
+ backend = 'cephadm',
+ configOptPermission = true
+ ) => {
+ let result: boolean;
+ spyOn(httpClient, 'get').and.returnValue(observableOf(getResult));
+ const orchBackend = { orchestrator: backend };
+ const getConfigSpy = spyOn(mgrModuleService, 'getConfig');
+ configOptPermission
+ ? getConfigSpy.and.returnValue(observableOf(orchBackend))
+ : getConfigSpy.and.returnValue(throwError({}));
+ ngZone.run(() => {
+ service.canActivateChild(route).subscribe((resp) => {
+ result = resp;
+ });
+ });
+
+ tick();
+ expect(result).toBe(activateResult);
+ expect(router.url).toBe(urlResult);
+ };
+
+ configureTestBed({
+ imports: [RouterTestingModule.withRoutes(routes)],
+ providers: [ModuleStatusGuardService, { provide: HttpClient, useValue: fakeService }],
+ declarations: [FooComponent]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(ModuleStatusGuardService);
+ httpClient = TestBed.inject(HttpClient);
+ mgrModuleService = TestBed.inject(MgrModuleService);
+ router = TestBed.inject(Router);
+ route = new ActivatedRouteSnapshot();
+ route.url = [];
+ route.data = {
+ moduleStatusGuardConfig: {
+ uiApiPath: 'bar',
+ redirectTo: '/foo',
+ backend: 'rook'
+ }
+ };
+ ngZone = TestBed.inject(NgZone);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should test canActivate with status available', fakeAsync(() => {
+ route.data.moduleStatusGuardConfig.redirectTo = 'foo';
+ testCanActivate({ available: true, message: 'foo' }, true, '/');
+ }));
+
+ it('should test canActivateChild with status unavailable', fakeAsync(() => {
+ testCanActivate({ available: false, message: null }, false, '/foo');
+ }));
+
+ it('should test canActivateChild with status unavailable', fakeAsync(() => {
+ testCanActivate(null, false, '/foo');
+ }));
+
+ it('should redirect normally if the backend provided matches the current backend', fakeAsync(() => {
+ testCanActivate({ available: true, message: 'foo' }, true, '/', 'rook');
+ }));
+
+ it('should redirect to the "redirectTo" link for user without sufficient permission', fakeAsync(() => {
+ testCanActivate({ available: true, message: 'foo' }, true, '/foo', 'rook', false);
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts
new file mode 100644
index 000000000..df6f4854e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts
@@ -0,0 +1,101 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router } from '@angular/router';
+
+import { of as observableOf } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+/**
+ * This service checks if a route can be activated by executing a
+ * REST API call to '/ui-api/<uiApiPath>/status'. If the returned response
+ * states that the module is not available, then the user is redirected
+ * to the specified <redirectTo> URL path.
+ *
+ * A controller implementing this endpoint should return an object of
+ * the following form:
+ * {'available': true|false, 'message': null|string}.
+ *
+ * The configuration of this guard should look like this:
+ * const routes: Routes = [
+ * {
+ * path: 'rgw/bucket',
+ * component: RgwBucketListComponent,
+ * canActivate: [AuthGuardService, ModuleStatusGuardService],
+ * data: {
+ * moduleStatusGuardConfig: {
+ * uiApiPath: 'rgw',
+ * redirectTo: 'rgw/501'
+ * }
+ * }
+ * },
+ * ...
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class ModuleStatusGuardService implements CanActivate, CanActivateChild {
+ // TODO: Hotfix - remove ALLOWLIST'ing when a generic ErrorComponent is implemented
+ static readonly ALLOWLIST: string[] = ['501'];
+
+ constructor(
+ private http: HttpClient,
+ private router: Router,
+ private mgrModuleService: MgrModuleService
+ ) {}
+
+ canActivate(route: ActivatedRouteSnapshot) {
+ return this.doCheck(route);
+ }
+
+ canActivateChild(childRoute: ActivatedRouteSnapshot) {
+ return this.doCheck(childRoute);
+ }
+
+ private doCheck(route: ActivatedRouteSnapshot) {
+ if (route.url.length > 0 && ModuleStatusGuardService.ALLOWLIST.includes(route.url[0].path)) {
+ return observableOf(true);
+ }
+ const config = route.data['moduleStatusGuardConfig'];
+ let backendCheck = false;
+ if (config.backend) {
+ this.mgrModuleService.getConfig('orchestrator').subscribe(
+ (resp) => {
+ backendCheck = config.backend === resp['orchestrator'];
+ },
+ () => {
+ this.router.navigate([config.redirectTo]);
+ return observableOf(false);
+ }
+ );
+ }
+ return this.http.get(`ui-api/${config.uiApiPath}/status`).pipe(
+ map((resp: any) => {
+ if (!resp.available && !backendCheck) {
+ this.router.navigate([config.redirectTo || ''], {
+ state: {
+ header: config.header,
+ message: resp.message,
+ section: config.section,
+ section_info: config.section_info,
+ button_name: config.button_name,
+ button_route: config.button_route,
+ button_title: config.button_title,
+ uiConfig: config.uiConfig,
+ uiApiPath: config.uiApiPath,
+ icon: Icons.wrench,
+ component: config.component
+ }
+ });
+ }
+ return resp.available;
+ }),
+ catchError(() => {
+ this.router.navigate([config.redirectTo]);
+ return observableOf(false);
+ })
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts
new file mode 100644
index 000000000..267e6aa57
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts
@@ -0,0 +1,117 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { Motd } from '~/app/shared/api/motd.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MotdNotificationService } from './motd-notification.service';
+
+describe('MotdNotificationService', () => {
+ let service: MotdNotificationService;
+
+ configureTestBed({
+ providers: [MotdNotificationService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(MotdNotificationService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should hide [1]', () => {
+ spyOn(service.motdSource, 'next');
+ spyOn(service.motdSource, 'getValue').and.returnValue({
+ severity: 'info',
+ expires: '',
+ message: 'foo',
+ md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+ });
+ service.hide();
+ expect(localStorage.getItem('dashboard_motd_hidden')).toBe(
+ 'info:acbd18db4cc2f85cedef654fccc4a4d8'
+ );
+ expect(sessionStorage.getItem('dashboard_motd_hidden')).toBeNull();
+ expect(service.motdSource.next).toBeCalledWith(null);
+ });
+
+ it('should hide [2]', () => {
+ spyOn(service.motdSource, 'getValue').and.returnValue({
+ severity: 'warning',
+ expires: '',
+ message: 'bar',
+ md5: '37b51d194a7513e45b56f6524f2d51f2'
+ });
+ service.hide();
+ expect(sessionStorage.getItem('dashboard_motd_hidden')).toBe(
+ 'warning:37b51d194a7513e45b56f6524f2d51f2'
+ );
+ expect(localStorage.getItem('dashboard_motd_hidden')).toBeNull();
+ });
+
+ it('should process response [1]', () => {
+ const motd: Motd = {
+ severity: 'danger',
+ expires: '',
+ message: 'foo',
+ md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+ };
+ spyOn(service.motdSource, 'next');
+ service.processResponse(motd);
+ expect(service.motdSource.next).toBeCalledWith(motd);
+ });
+
+ it('should process response [2]', () => {
+ const motd: Motd = {
+ severity: 'warning',
+ expires: '',
+ message: 'foo',
+ md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+ };
+ localStorage.setItem('dashboard_motd_hidden', 'info');
+ service.processResponse(motd);
+ expect(sessionStorage.getItem('dashboard_motd_hidden')).toBeNull();
+ expect(localStorage.getItem('dashboard_motd_hidden')).toBeNull();
+ });
+
+ it('should process response [3]', () => {
+ const motd: Motd = {
+ severity: 'info',
+ expires: '',
+ message: 'foo',
+ md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+ };
+ spyOn(service.motdSource, 'next');
+ localStorage.setItem('dashboard_motd_hidden', 'info:acbd18db4cc2f85cedef654fccc4a4d8');
+ service.processResponse(motd);
+ expect(service.motdSource.next).not.toBeCalled();
+ });
+
+ it('should process response [4]', () => {
+ const motd: Motd = {
+ severity: 'info',
+ expires: '',
+ message: 'foo',
+ md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+ };
+ spyOn(service.motdSource, 'next');
+ localStorage.setItem('dashboard_motd_hidden', 'info:37b51d194a7513e45b56f6524f2d51f2');
+ service.processResponse(motd);
+ expect(service.motdSource.next).toBeCalled();
+ });
+
+ it('should process response [5]', () => {
+ const motd: Motd = {
+ severity: 'info',
+ expires: '',
+ message: 'foo',
+ md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+ };
+ spyOn(service.motdSource, 'next');
+ localStorage.setItem('dashboard_motd_hidden', 'danger:acbd18db4cc2f85cedef654fccc4a4d8');
+ service.processResponse(motd);
+ expect(service.motdSource.next).toBeCalled();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts
new file mode 100644
index 000000000..d2ee89f9c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts
@@ -0,0 +1,84 @@
+import { Injectable, OnDestroy } from '@angular/core';
+
+import * as _ from 'lodash';
+import { BehaviorSubject, EMPTY, Observable, of, Subscription } from 'rxjs';
+import { catchError, delay, mergeMap, repeat, tap } from 'rxjs/operators';
+
+import { Motd, MotdService } from '~/app/shared/api/motd.service';
+import { whenPageVisible } from '../rxjs/operators/page-visibilty.operator';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class MotdNotificationService implements OnDestroy {
+ public motd$: Observable<Motd | null>;
+ public motdSource = new BehaviorSubject<Motd | null>(null);
+
+ private subscription: Subscription;
+ private localStorageKey = 'dashboard_motd_hidden';
+
+ constructor(private motdService: MotdService) {
+ this.motd$ = this.motdSource.asObservable();
+ // Check every 60 seconds for the latest MOTD configuration.
+ this.subscription = of(true)
+ .pipe(
+ mergeMap(() => this.motdService.get()),
+ catchError((error) => {
+ // Do not show an error notification.
+ if (_.isFunction(error.preventDefault)) {
+ error.preventDefault();
+ }
+ return EMPTY;
+ }),
+ tap((motd: Motd | null) => this.processResponse(motd)),
+ delay(60000),
+ repeat(),
+ whenPageVisible()
+ )
+ .subscribe();
+ }
+
+ ngOnDestroy(): void {
+ this.subscription.unsubscribe();
+ }
+
+ hide() {
+ // Store the severity and MD5 of the current MOTD in local or
+ // session storage to be able to show it again if the severity
+ // or message of the latest MOTD has changed.
+ const motd: Motd = this.motdSource.getValue();
+ if (motd) {
+ const value = `${motd.severity}:${motd.md5}`;
+ switch (motd.severity) {
+ case 'info':
+ localStorage.setItem(this.localStorageKey, value);
+ sessionStorage.removeItem(this.localStorageKey);
+ break;
+ case 'warning':
+ sessionStorage.setItem(this.localStorageKey, value);
+ localStorage.removeItem(this.localStorageKey);
+ break;
+ }
+ }
+ this.motdSource.next(null);
+ }
+
+ processResponse(motd: Motd | null) {
+ const value: string | null =
+ sessionStorage.getItem(this.localStorageKey) || localStorage.getItem(this.localStorageKey);
+ let visible: boolean = _.isNull(value);
+ // Force a hidden MOTD to be shown again if the severity or message
+ // has been changed.
+ if (!visible && motd) {
+ const [severity, md5] = value.split(':');
+ if (severity !== motd.severity || md5 !== motd.md5) {
+ visible = true;
+ sessionStorage.removeItem(this.localStorageKey);
+ localStorage.removeItem(this.localStorageKey);
+ }
+ }
+ if (visible) {
+ this.motdSource.next(motd);
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/ngzone-scheduler.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/ngzone-scheduler.service.ts
new file mode 100644
index 000000000..a2c6b6c95
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/ngzone-scheduler.service.ts
@@ -0,0 +1,48 @@
+import { Injectable, NgZone } from '@angular/core';
+
+import { asyncScheduler, SchedulerLike, Subscription } from 'rxjs';
+
+abstract class NgZoneScheduler implements SchedulerLike {
+ protected scheduler = asyncScheduler;
+
+ constructor(protected zone: NgZone) {}
+
+ abstract schedule(...args: any[]): Subscription;
+
+ now(): number {
+ return this.scheduler.now();
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LeaveNgZoneScheduler extends NgZoneScheduler {
+ constructor(zone: NgZone) {
+ super(zone);
+ }
+
+ schedule(...args: any[]): Subscription {
+ return this.zone.runOutsideAngular(() => this.scheduler.schedule.apply(this.scheduler, args));
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class EnterNgZoneScheduler extends NgZoneScheduler {
+ constructor(zone: NgZone) {
+ super(zone);
+ }
+
+ schedule(...args: any[]): Subscription {
+ return this.zone.run(() => this.scheduler.schedule.apply(this.scheduler, args));
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NgZoneSchedulerService {
+ constructor(public leave: LeaveNgZoneScheduler, public enter: EnterNgZoneScheduler) {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts
new file mode 100644
index 000000000..9a330cdc8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts
@@ -0,0 +1,49 @@
+import { Component, NgZone } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { DashboardUserDeniedError } from '~/app/core/error/error';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { AuthStorageService } from './auth-storage.service';
+import { NoSsoGuardService } from './no-sso-guard.service';
+
+describe('NoSsoGuardService', () => {
+ let service: NoSsoGuardService;
+ let authStorageService: AuthStorageService;
+ let ngZone: NgZone;
+
+ @Component({ selector: 'cd-404', template: '' })
+ class NotFoundComponent {}
+
+ const routes: Routes = [{ path: '404', component: NotFoundComponent }];
+
+ configureTestBed({
+ imports: [RouterTestingModule.withRoutes(routes)],
+ providers: [NoSsoGuardService, AuthStorageService],
+ declarations: [NotFoundComponent]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(NoSsoGuardService);
+ authStorageService = TestBed.inject(AuthStorageService);
+ ngZone = TestBed.inject(NgZone);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should allow if not logged in via SSO', () => {
+ spyOn(authStorageService, 'isSSO').and.returnValue(false);
+ expect(service.canActivate()).toBe(true);
+ });
+
+ it('should prevent if logged in via SSO', fakeAsync(() => {
+ spyOn(authStorageService, 'isSSO').and.returnValue(true);
+ ngZone.run(() => {
+ expect(() => service.canActivate()).toThrowError(DashboardUserDeniedError);
+ });
+ tick();
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts
new file mode 100644
index 000000000..d4abcde0d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@angular/core';
+import { CanActivate, CanActivateChild } from '@angular/router';
+
+import { DashboardUserDeniedError } from '~/app/core/error/error';
+import { AuthStorageService } from './auth-storage.service';
+
+/**
+ * This service checks if a route can be activated if the user has not
+ * been logged in via SSO.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class NoSsoGuardService implements CanActivate, CanActivateChild {
+ constructor(private authStorageService: AuthStorageService) {}
+
+ canActivate() {
+ if (!this.authStorageService.isSSO()) {
+ return true;
+ }
+ throw new DashboardUserDeniedError();
+ return false;
+ }
+
+ canActivateChild(): boolean {
+ return this.canActivate();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
new file mode 100644
index 000000000..028dd90ea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
@@ -0,0 +1,285 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import _ from 'lodash';
+import { ToastrService } from 'ngx-toastr';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdService } from '../api/rbd.service';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { FinishedTask } from '../models/finished-task';
+import { CdDatePipe } from '../pipes/cd-date.pipe';
+import { NotificationService } from './notification.service';
+import { TaskMessageService } from './task-message.service';
+
+describe('NotificationService', () => {
+ let service: NotificationService;
+ const toastFakeService = {
+ error: () => true,
+ info: () => true,
+ success: () => true
+ };
+
+ configureTestBed({
+ providers: [
+ NotificationService,
+ TaskMessageService,
+ { provide: ToastrService, useValue: toastFakeService },
+ { provide: CdDatePipe, useValue: { transform: (d: any) => d } },
+ RbdService
+ ],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(NotificationService);
+ service.removeAll();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should read empty notification list', () => {
+ localStorage.setItem('cdNotifications', '[]');
+ expect(service['dataSource'].getValue()).toEqual([]);
+ });
+
+ it('should read old notifications', fakeAsync(() => {
+ localStorage.setItem(
+ 'cdNotifications',
+ '[{"type":2,"message":"foobar","timestamp":"2018-05-24T09:41:32.726Z"}]'
+ );
+ service = new NotificationService(null, null, null);
+ expect(service['dataSource'].getValue().length).toBe(1);
+ }));
+
+ it('should cancel a notification', fakeAsync(() => {
+ const timeoutId = service.show(NotificationType.error, 'Simple test');
+ service.cancel(timeoutId);
+ tick(5000);
+ expect(service['dataSource'].getValue().length).toBe(0);
+ }));
+
+ describe('Saved notifications', () => {
+ const expectSavedNotificationToHave = (expected: object) => {
+ tick(510);
+ expect(service['dataSource'].getValue().length).toBe(1);
+ const notification = service['dataSource'].getValue()[0];
+ Object.keys(expected).forEach((key) => {
+ expect(notification[key]).toBe(expected[key]);
+ });
+ };
+
+ const addNotifications = (quantity: number) => {
+ for (let index = 0; index < quantity; index++) {
+ service.show(NotificationType.info, `${index}`);
+ tick(510);
+ }
+ };
+
+ beforeEach(() => {
+ spyOn(service, 'show').and.callThrough();
+ service.cancel((<any>service)['justShownTimeoutId']);
+ });
+
+ it('should create a success notification and save it', fakeAsync(() => {
+ service.show(new CdNotificationConfig(NotificationType.success, 'Simple test'));
+ expectSavedNotificationToHave({ type: NotificationType.success });
+ }));
+
+ it('should create an error notification and save it', fakeAsync(() => {
+ service.show(NotificationType.error, 'Simple test');
+ expectSavedNotificationToHave({ type: NotificationType.error });
+ }));
+
+ it('should create an info notification and save it', fakeAsync(() => {
+ service.show(new CdNotificationConfig(NotificationType.info, 'Simple test'));
+ expectSavedNotificationToHave({
+ type: NotificationType.info,
+ title: 'Simple test',
+ message: undefined
+ });
+ }));
+
+ it('should never have more then 10 notifications', fakeAsync(() => {
+ addNotifications(15);
+ expect(service['dataSource'].getValue().length).toBe(10);
+ }));
+
+ it('should show a success task notification, but not save it', fakeAsync(() => {
+ const task = _.assign(new FinishedTask(), {
+ success: true
+ });
+
+ service.notifyTask(task, true);
+ tick(1500);
+
+ expect(service.show).toHaveBeenCalled();
+ const notifications = service['dataSource'].getValue();
+ expect(notifications.length).toBe(0);
+ }));
+
+ it('should be able to stop notifyTask from notifying', fakeAsync(() => {
+ const task = _.assign(new FinishedTask(), {
+ success: true
+ });
+ const timeoutId = service.notifyTask(task, true);
+ service.cancel(timeoutId);
+ tick(100);
+ expect(service['dataSource'].getValue().length).toBe(0);
+ }));
+
+ it('should show a error task notification', fakeAsync(() => {
+ const task = _.assign(
+ new FinishedTask('rbd/create', {
+ pool_name: 'somePool',
+ image_name: 'someImage'
+ }),
+ {
+ success: false,
+ exception: {
+ code: 17
+ }
+ }
+ );
+ service.notifyTask(task);
+
+ tick(1500);
+
+ expect(service.show).toHaveBeenCalled();
+ const notifications = service['dataSource'].getValue();
+ expect(notifications.length).toBe(0);
+ }));
+
+ it('combines different notifications with the same title', fakeAsync(() => {
+ service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
+ tick(60);
+ service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path b');
+ expectSavedNotificationToHave({
+ type: NotificationType.error,
+ title: '502 - Bad Gateway',
+ message: '<ul><li>Error occurred in path a</li><li>Error occurred in path b</li></ul>'
+ });
+ }));
+
+ it('should remove a single notification', fakeAsync(() => {
+ addNotifications(5);
+ let messages = service['dataSource'].getValue().map((notification) => notification.title);
+ expect(messages).toEqual(['4', '3', '2', '1', '0']);
+ service.remove(2);
+ messages = service['dataSource'].getValue().map((notification) => notification.title);
+ expect(messages).toEqual(['4', '3', '1', '0']);
+ }));
+
+ it('should remove all notifications', fakeAsync(() => {
+ addNotifications(5);
+ expect(service['dataSource'].getValue().length).toBe(5);
+ service.removeAll();
+ expect(service['dataSource'].getValue().length).toBe(0);
+ }));
+ });
+
+ describe('notification queue', () => {
+ const n1 = new CdNotificationConfig(NotificationType.success, 'Some success');
+ const n2 = new CdNotificationConfig(NotificationType.info, 'Some info');
+
+ const showArray = (arr: any[]) => arr.forEach((n) => service.show(n));
+
+ beforeEach(() => {
+ spyOn(service, 'save').and.stub();
+ });
+
+ it('filters out duplicated notifications on single call', fakeAsync(() => {
+ showArray([n1, n1, n2, n2]);
+ tick(510);
+ expect(service.save).toHaveBeenCalledTimes(2);
+ }));
+
+ it('filters out duplicated notifications presented in different calls', fakeAsync(() => {
+ showArray([n1, n2]);
+ showArray([n1, n2]);
+ tick(1000);
+ expect(service.save).toHaveBeenCalledTimes(2);
+ }));
+
+ it('will reset the timeout on every call', fakeAsync(() => {
+ showArray([n1, n2]);
+ tick(490);
+ showArray([n1, n2]);
+ tick(450);
+ expect(service.save).toHaveBeenCalledTimes(0);
+ tick(60);
+ expect(service.save).toHaveBeenCalledTimes(2);
+ }));
+
+ it('wont filter out duplicated notifications if timeout was reached before', fakeAsync(() => {
+ showArray([n1, n2]);
+ tick(510);
+ showArray([n1, n2]);
+ tick(510);
+ expect(service.save).toHaveBeenCalledTimes(4);
+ }));
+ });
+
+ describe('showToasty', () => {
+ let toastr: ToastrService;
+ const time = '2022-02-22T00:00:00.000Z';
+
+ beforeEach(() => {
+ const baseTime = new Date(time);
+ spyOn(global, 'Date').and.returnValue(baseTime);
+ spyOn(window, 'setTimeout').and.callFake((fn) => fn());
+
+ toastr = TestBed.inject(ToastrService);
+ // spyOn needs to know the methods before spying and can't read the array for clarification
+ ['error', 'info', 'success'].forEach((method: 'error' | 'info' | 'success') =>
+ spyOn(toastr, method).and.stub()
+ );
+ });
+
+ it('should show with only title defined', () => {
+ service.show(NotificationType.info, 'Some info');
+ expect(toastr.info).toHaveBeenCalledWith(
+ `<small class="date">${time}</small>` +
+ '<i class="float-right custom-icon ceph-icon" title="Ceph"></i>',
+ 'Some info',
+ undefined
+ );
+ });
+
+ it('should show with title and message defined', () => {
+ service.show(
+ () =>
+ new CdNotificationConfig(NotificationType.error, 'Some error', 'Some operation failed')
+ );
+ expect(toastr.error).toHaveBeenCalledWith(
+ 'Some operation failed<br>' +
+ `<small class="date">${time}</small>` +
+ '<i class="float-right custom-icon ceph-icon" title="Ceph"></i>',
+ 'Some error',
+ undefined
+ );
+ });
+
+ it('should show with title, message and application defined', () => {
+ service.show(
+ new CdNotificationConfig(
+ NotificationType.success,
+ 'Alert resolved',
+ 'Some alert resolved',
+ undefined,
+ 'Prometheus'
+ )
+ );
+ expect(toastr.success).toHaveBeenCalledWith(
+ 'Some alert resolved<br>' +
+ `<small class="date">${time}</small>` +
+ '<i class="float-right custom-icon prometheus-icon" title="Prometheus"></i>',
+ 'Alert resolved',
+ undefined
+ );
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
new file mode 100644
index 000000000..c05dbce0f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
@@ -0,0 +1,237 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { IndividualConfig, ToastrService } from 'ngx-toastr';
+import { BehaviorSubject, Subject } from 'rxjs';
+
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
+import { FinishedTask } from '../models/finished-task';
+import { CdDatePipe } from '../pipes/cd-date.pipe';
+import { TaskMessageService } from './task-message.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NotificationService {
+ private hideToasties = false;
+
+ // Data observable
+ private dataSource = new BehaviorSubject<CdNotification[]>([]);
+ data$ = this.dataSource.asObservable();
+
+ // Sidebar observable
+ sidebarSubject = new Subject();
+
+ private queued: CdNotificationConfig[] = [];
+ private queuedTimeoutId: number;
+ KEY = 'cdNotifications';
+
+ constructor(
+ public toastr: ToastrService,
+ private taskMessageService: TaskMessageService,
+ private cdDatePipe: CdDatePipe
+ ) {
+ const stringNotifications = localStorage.getItem(this.KEY);
+ let notifications: CdNotification[] = [];
+
+ if (_.isString(stringNotifications)) {
+ notifications = JSON.parse(stringNotifications, (_key, value) => {
+ if (_.isPlainObject(value)) {
+ return _.assign(new CdNotification(), value);
+ }
+ return value;
+ });
+ }
+
+ this.dataSource.next(notifications);
+ }
+
+ /**
+ * Removes all current saved notifications
+ */
+ removeAll() {
+ localStorage.removeItem(this.KEY);
+ this.dataSource.next([]);
+ }
+
+ /**
+ * Removes a single saved notifications
+ */
+ remove(index: number) {
+ const recent = this.dataSource.getValue();
+ recent.splice(index, 1);
+ this.dataSource.next(recent);
+ localStorage.setItem(this.KEY, JSON.stringify(recent));
+ }
+
+ /**
+ * Method used for saving a shown notification (check show() method).
+ */
+ save(notification: CdNotification) {
+ const recent = this.dataSource.getValue();
+ recent.push(notification);
+ recent.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1));
+ while (recent.length > 10) {
+ recent.pop();
+ }
+ this.dataSource.next(recent);
+ localStorage.setItem(this.KEY, JSON.stringify(recent));
+ }
+
+ /**
+ * Method for showing a notification.
+ * @param {NotificationType} type toastr type
+ * @param {string} title
+ * @param {string} [message] The message to be displayed. Note, use this field
+ * for error notifications only.
+ * @param {*} [options] toastr compatible options, used when creating a toastr
+ * @param {string} [application] Only needed if notification comes from an external application
+ * @returns The timeout ID that is set to be able to cancel the notification.
+ */
+ show(
+ type: NotificationType,
+ title: string,
+ message?: string,
+ options?: any | IndividualConfig,
+ application?: string
+ ): number;
+ show(config: CdNotificationConfig | (() => CdNotificationConfig)): number;
+ show(
+ arg: NotificationType | CdNotificationConfig | (() => CdNotificationConfig),
+ title?: string,
+ message?: string,
+ options?: any | IndividualConfig,
+ application?: string
+ ): number {
+ return window.setTimeout(() => {
+ let config: CdNotificationConfig;
+ if (_.isFunction(arg)) {
+ config = arg() as CdNotificationConfig;
+ } else if (_.isObject(arg)) {
+ config = arg as CdNotificationConfig;
+ } else {
+ config = new CdNotificationConfig(
+ arg as NotificationType,
+ title,
+ message,
+ options,
+ application
+ );
+ }
+ this.queueToShow(config);
+ }, 10);
+ }
+
+ private queueToShow(config: CdNotificationConfig) {
+ this.cancel(this.queuedTimeoutId);
+ if (!this.queued.find((c) => _.isEqual(c, config))) {
+ this.queued.push(config);
+ }
+ this.queuedTimeoutId = window.setTimeout(() => {
+ this.showQueued();
+ }, 500);
+ }
+
+ private showQueued() {
+ this.getUnifiedTitleQueue().forEach((config) => {
+ const notification = new CdNotification(config);
+
+ if (!notification.isFinishedTask) {
+ this.save(notification);
+ }
+ this.showToasty(notification);
+ });
+ }
+
+ private getUnifiedTitleQueue(): CdNotificationConfig[] {
+ return Object.values(this.queueShiftByTitle()).map((configs) => {
+ const config = configs[0];
+ if (configs.length > 1) {
+ config.message = '<ul>' + configs.map((c) => `<li>${c.message}</li>`).join('') + '</ul>';
+ }
+ return config;
+ });
+ }
+
+ private queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } {
+ const byTitle: { [key: string]: CdNotificationConfig[] } = {};
+ let config: CdNotificationConfig;
+ while ((config = this.queued.shift())) {
+ if (!byTitle[config.title]) {
+ byTitle[config.title] = [];
+ }
+ byTitle[config.title].push(config);
+ }
+ return byTitle;
+ }
+
+ private showToasty(notification: CdNotification) {
+ // Exit immediately if no toasty should be displayed.
+ if (this.hideToasties) {
+ return;
+ }
+ this.toastr[['error', 'info', 'success'][notification.type]](
+ (notification.message ? notification.message + '<br>' : '') +
+ this.renderTimeAndApplicationHtml(notification),
+ notification.title,
+ notification.options
+ );
+ }
+
+ renderTimeAndApplicationHtml(notification: CdNotification): string {
+ return `<small class="date">${this.cdDatePipe.transform(
+ notification.timestamp
+ )}</small><i class="float-right custom-icon ${notification.applicationClass}" title="${
+ notification.application
+ }"></i>`;
+ }
+
+ notifyTask(finishedTask: FinishedTask, success: boolean = true): number {
+ const notification = this.finishedTaskToNotification(finishedTask, success);
+ notification.isFinishedTask = true;
+ return this.show(notification);
+ }
+
+ finishedTaskToNotification(
+ finishedTask: FinishedTask,
+ success: boolean = true
+ ): CdNotificationConfig {
+ let notification: CdNotificationConfig;
+ if (finishedTask.success && success) {
+ notification = new CdNotificationConfig(
+ NotificationType.success,
+ this.taskMessageService.getSuccessTitle(finishedTask)
+ );
+ } else {
+ notification = new CdNotificationConfig(
+ NotificationType.error,
+ this.taskMessageService.getErrorTitle(finishedTask),
+ this.taskMessageService.getErrorMessage(finishedTask)
+ );
+ }
+ notification.isFinishedTask = true;
+
+ return notification;
+ }
+
+ /**
+ * Prevent the notification from being shown.
+ * @param {number} timeoutId A number representing the ID of the timeout to be canceled.
+ */
+ cancel(timeoutId: number) {
+ window.clearTimeout(timeoutId);
+ }
+
+ /**
+ * Suspend showing the notification toasties.
+ * @param {boolean} suspend Set to ``true`` to disable/hide toasties.
+ */
+ suspendToasties(suspend: boolean) {
+ this.hideToasties = suspend;
+ }
+
+ toggleSidebar(forceClose = false) {
+ this.sidebarSubject.next(forceClose);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts
new file mode 100644
index 000000000..2925b152b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts
@@ -0,0 +1,208 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { of as observableOf } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SettingsService } from '../api/settings.service';
+import { SharedModule } from '../shared.module';
+import { PasswordPolicyService } from './password-policy.service';
+
+describe('PasswordPolicyService', () => {
+ let service: PasswordPolicyService;
+ let settingsService: SettingsService;
+
+ const helpTextHelper = {
+ get: (chk: string) => {
+ const chkTexts: { [key: string]: string } = {
+ chk_length: 'Must contain at least 10 characters',
+ chk_oldpwd: 'Must not be the same as the previous one',
+ chk_username: 'Cannot contain the username',
+ chk_exclusion_list: 'Cannot contain any configured keyword',
+ chk_repetitive: 'Cannot contain any repetitive characters e.g. "aaa"',
+ chk_sequential: 'Cannot contain any sequential characters e.g. "abc"',
+ chk_complexity:
+ 'Must consist of characters from the following groups:\n' +
+ ' * Alphabetic a-z, A-Z\n' +
+ ' * Numbers 0-9\n' +
+ ' * Special chars: !"#$%& \'()*+,-./:;<=>?@[\\]^_`{{|}}~\n' +
+ ' * Any other characters (signs)'
+ };
+ return ['Required rules for passwords:', '- ' + chkTexts[chk]].join('\n');
+ }
+ };
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(PasswordPolicyService);
+ settingsService = TestBed.inject(SettingsService);
+ settingsService['settings'] = {};
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should not get help text', () => {
+ let helpText = '';
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ pwd_policy_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe('');
+ });
+
+ it('should get help text chk_length', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_length');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ user_pwd_expiration_warning_1: 10,
+ user_pwd_expiration_warning_2: 5,
+ user_pwd_expiration_span: 90,
+ pwd_policy_enabled: true,
+ pwd_policy_min_length: 10,
+ pwd_policy_check_length_enabled: true,
+ pwd_policy_check_oldpwd_enabled: false,
+ pwd_policy_check_sequential_chars_enabled: false,
+ pwd_policy_check_complexity_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get help text chk_oldpwd', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_oldpwd');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ pwd_policy_enabled: true,
+ pwd_policy_check_oldpwd_enabled: true,
+ pwd_policy_check_username_enabled: false,
+ pwd_policy_check_exclusion_list_enabled: false,
+ pwd_policy_check_complexity_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get help text chk_username', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_username');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ pwd_policy_enabled: true,
+ pwd_policy_check_oldpwd_enabled: false,
+ pwd_policy_check_username_enabled: true,
+ pwd_policy_check_exclusion_list_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get help text chk_exclusion_list', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_exclusion_list');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ pwd_policy_enabled: true,
+ pwd_policy_check_username_enabled: false,
+ pwd_policy_check_exclusion_list_enabled: true,
+ pwd_policy_check_repetitive_chars_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get help text chk_repetitive', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_repetitive');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ user_pwd_expiration_warning_1: 10,
+ pwd_policy_enabled: true,
+ pwd_policy_check_oldpwd_enabled: false,
+ pwd_policy_check_exclusion_list_enabled: false,
+ pwd_policy_check_repetitive_chars_enabled: true,
+ pwd_policy_check_sequential_chars_enabled: false,
+ pwd_policy_check_complexity_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get help text chk_sequential', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_sequential');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ pwd_policy_enabled: true,
+ pwd_policy_min_length: 8,
+ pwd_policy_check_length_enabled: false,
+ pwd_policy_check_oldpwd_enabled: false,
+ pwd_policy_check_username_enabled: false,
+ pwd_policy_check_exclusion_list_enabled: false,
+ pwd_policy_check_repetitive_chars_enabled: false,
+ pwd_policy_check_sequential_chars_enabled: true,
+ pwd_policy_check_complexity_enabled: false
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get help text chk_complexity', () => {
+ let helpText = '';
+ const expectedHelpText = helpTextHelper.get('chk_complexity');
+ spyOn(settingsService, 'getStandardSettings').and.returnValue(
+ observableOf({
+ pwd_policy_enabled: true,
+ pwd_policy_min_length: 8,
+ pwd_policy_check_length_enabled: false,
+ pwd_policy_check_oldpwd_enabled: false,
+ pwd_policy_check_username_enabled: false,
+ pwd_policy_check_exclusion_list_enabled: false,
+ pwd_policy_check_repetitive_chars_enabled: false,
+ pwd_policy_check_sequential_chars_enabled: false,
+ pwd_policy_check_complexity_enabled: true
+ })
+ );
+ service.getHelpText().subscribe((text) => (helpText = text));
+ expect(helpText).toBe(expectedHelpText);
+ });
+
+ it('should get too-weak class', () => {
+ expect(service.mapCreditsToCssClass(0)).toBe('too-weak');
+ expect(service.mapCreditsToCssClass(9)).toBe('too-weak');
+ });
+
+ it('should get weak class', () => {
+ expect(service.mapCreditsToCssClass(10)).toBe('weak');
+ expect(service.mapCreditsToCssClass(14)).toBe('weak');
+ });
+
+ it('should get ok class', () => {
+ expect(service.mapCreditsToCssClass(15)).toBe('ok');
+ expect(service.mapCreditsToCssClass(19)).toBe('ok');
+ });
+
+ it('should get strong class', () => {
+ expect(service.mapCreditsToCssClass(20)).toBe('strong');
+ expect(service.mapCreditsToCssClass(24)).toBe('strong');
+ });
+
+ it('should get very-strong class', () => {
+ expect(service.mapCreditsToCssClass(25)).toBe('very-strong');
+ expect(service.mapCreditsToCssClass(30)).toBe('very-strong');
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts
new file mode 100644
index 000000000..295420c27
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts
@@ -0,0 +1,65 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { SettingsService } from '../api/settings.service';
+import { CdPwdPolicySettings } from '../models/cd-pwd-policy-settings';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PasswordPolicyService {
+ constructor(private settingsService: SettingsService) {}
+
+ getHelpText(): Observable<string> {
+ return this.settingsService.getStandardSettings().pipe(
+ map((resp: { [key: string]: any }) => {
+ const settings = new CdPwdPolicySettings(resp);
+ let helpText: string[] = [];
+ if (settings.pwdPolicyEnabled) {
+ helpText.push($localize`Required rules for passwords:`);
+ const i18nHelp: { [key: string]: string } = {
+ pwdPolicyCheckLengthEnabled: $localize`Must contain at least ${settings.pwdPolicyMinLength} characters`,
+ pwdPolicyCheckOldpwdEnabled: $localize`Must not be the same as the previous one`,
+ pwdPolicyCheckUsernameEnabled: $localize`Cannot contain the username`,
+ pwdPolicyCheckExclusionListEnabled: $localize`Cannot contain any configured keyword`,
+ pwdPolicyCheckRepetitiveCharsEnabled: $localize`Cannot contain any repetitive characters e.g. "aaa"`,
+ pwdPolicyCheckSequentialCharsEnabled: $localize`Cannot contain any sequential characters e.g. "abc"`,
+ pwdPolicyCheckComplexityEnabled: $localize`Must consist of characters from the following groups:
+ * Alphabetic a-z, A-Z
+ * Numbers 0-9
+ * Special chars: !"#$%& '()*+,-./:;<=>?@[\\]^_\`{{|}}~
+ * Any other characters (signs)`
+ };
+ helpText = helpText.concat(
+ _.keys(i18nHelp)
+ .filter((key) => _.get(settings, key))
+ .map((key) => '- ' + _.get(i18nHelp, key))
+ );
+ }
+ return helpText.join('\n');
+ })
+ );
+ }
+
+ /**
+ * Helper function to map password policy credits to a CSS class.
+ * @param credits The password policy credits.
+ * @return The name of the CSS class.
+ */
+ mapCreditsToCssClass(credits: number): string {
+ let result = 'very-strong';
+ if (credits < 10) {
+ result = 'too-weak';
+ } else if (credits < 15) {
+ result = 'weak';
+ } else if (credits < 20) {
+ result = 'ok';
+ } else if (credits < 25) {
+ result = 'strong';
+ }
+ return result;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts
new file mode 100644
index 000000000..1384637bb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts
@@ -0,0 +1,95 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { PrometheusCustomAlert } from '../models/prometheus-alerts';
+import { SharedModule } from '../shared.module';
+import { NotificationService } from './notification.service';
+import { PrometheusAlertFormatter } from './prometheus-alert-formatter';
+
+describe('PrometheusAlertFormatter', () => {
+ let service: PrometheusAlertFormatter;
+ let notificationService: NotificationService;
+ let prometheus: PrometheusHelper;
+
+ configureTestBed({
+ imports: [ToastrModule.forRoot(), SharedModule, HttpClientTestingModule],
+ providers: [PrometheusAlertFormatter]
+ });
+
+ beforeEach(() => {
+ prometheus = new PrometheusHelper();
+ service = TestBed.inject(PrometheusAlertFormatter);
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+ });
+
+ it('should create', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('sendNotifications', () => {
+ it('should not call queue notifications with no notification', () => {
+ service.sendNotifications([]);
+ expect(notificationService.show).not.toHaveBeenCalled();
+ });
+
+ it('should call queue notifications with notifications', () => {
+ const notifications = [new CdNotificationConfig(NotificationType.success, 'test')];
+ service.sendNotifications(notifications);
+ expect(notificationService.show).toHaveBeenCalledWith(notifications[0]);
+ });
+ });
+
+ describe('convertToCustomAlert', () => {
+ it('converts PrometheusAlert', () => {
+ expect(service.convertToCustomAlerts([prometheus.createAlert('Something')])).toEqual([
+ {
+ status: 'active',
+ name: 'Something',
+ description: 'Something is active',
+ url: 'http://Something',
+ fingerprint: 'Something'
+ } as PrometheusCustomAlert
+ ]);
+ });
+
+ it('converts PrometheusNotificationAlert', () => {
+ expect(
+ service.convertToCustomAlerts([prometheus.createNotificationAlert('Something')])
+ ).toEqual([
+ {
+ fingerprint: false,
+ status: 'active',
+ name: 'Something',
+ description: 'Something is firing',
+ url: 'http://Something'
+ } as PrometheusCustomAlert
+ ]);
+ });
+ });
+
+ it('converts custom alert into notification', () => {
+ const alert: PrometheusCustomAlert = {
+ status: 'active',
+ name: 'Some alert',
+ description: 'Some alert is active',
+ url: 'http://some-alert',
+ fingerprint: '42'
+ };
+ expect(service.convertAlertToNotification(alert)).toEqual(
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'Some alert (active)',
+ 'Some alert is active <a href="http://some-alert" target="_blank">' +
+ '<i class="fa fa-line-chart"></i></a>',
+ undefined,
+ 'Prometheus'
+ )
+ );
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts
new file mode 100644
index 000000000..96ad5f96f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts
@@ -0,0 +1,74 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+import { Icons } from '../enum/icons.enum';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import {
+ AlertmanagerAlert,
+ AlertmanagerNotificationAlert,
+ PrometheusCustomAlert
+} from '../models/prometheus-alerts';
+import { NotificationService } from './notification.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PrometheusAlertFormatter {
+ constructor(private notificationService: NotificationService) {}
+
+ sendNotifications(notifications: CdNotificationConfig[]) {
+ notifications.forEach((n) => this.notificationService.show(n));
+ }
+
+ convertToCustomAlerts(
+ alerts: (AlertmanagerNotificationAlert | AlertmanagerAlert)[]
+ ): PrometheusCustomAlert[] {
+ return _.uniqWith(
+ alerts.map((alert) => {
+ return {
+ status: _.isObject(alert.status)
+ ? (alert as AlertmanagerAlert).status.state
+ : this.getPrometheusNotificationStatus(alert as AlertmanagerNotificationAlert),
+ name: alert.labels.alertname,
+ url: alert.generatorURL,
+ description: alert.annotations.description,
+ fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint
+ };
+ }),
+ _.isEqual
+ ) as PrometheusCustomAlert[];
+ }
+
+ /*
+ * This is needed because NotificationAlerts don't use 'active'
+ */
+ private getPrometheusNotificationStatus(alert: AlertmanagerNotificationAlert): string {
+ const state = alert.status;
+ return state === 'firing' ? 'active' : state;
+ }
+
+ convertAlertToNotification(alert: PrometheusCustomAlert): CdNotificationConfig {
+ return new CdNotificationConfig(
+ this.formatType(alert.status),
+ `${alert.name} (${alert.status})`,
+ this.appendSourceLink(alert, alert.description),
+ undefined,
+ 'Prometheus'
+ );
+ }
+
+ private formatType(status: string): NotificationType {
+ const types = {
+ error: ['firing', 'active'],
+ info: ['suppressed', 'unprocessed'],
+ success: ['resolved']
+ };
+ return NotificationType[_.findKey(types, (type) => type.includes(status))];
+ }
+
+ private appendSourceLink(alert: PrometheusCustomAlert, message: string): string {
+ return `${message} <a href="${alert.url}" target="_blank"><i class="${Icons.lineChart}"></i></a>`;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts
new file mode 100644
index 000000000..aa3160b30
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts
@@ -0,0 +1,214 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { Observable, of } from 'rxjs';
+
+import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper';
+import { PrometheusService } from '../api/prometheus.service';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { AlertmanagerAlert } from '../models/prometheus-alerts';
+import { SharedModule } from '../shared.module';
+import { NotificationService } from './notification.service';
+import { PrometheusAlertFormatter } from './prometheus-alert-formatter';
+import { PrometheusAlertService } from './prometheus-alert.service';
+
+describe('PrometheusAlertService', () => {
+ let service: PrometheusAlertService;
+ let notificationService: NotificationService;
+ let alerts: AlertmanagerAlert[];
+ let prometheusService: PrometheusService;
+ let prometheus: PrometheusHelper;
+
+ configureTestBed({
+ imports: [ToastrModule.forRoot(), SharedModule, HttpClientTestingModule],
+ providers: [PrometheusAlertService, PrometheusAlertFormatter]
+ });
+
+ beforeEach(() => {
+ prometheus = new PrometheusHelper();
+ });
+
+ it('should create', () => {
+ expect(TestBed.inject(PrometheusAlertService)).toBeTruthy();
+ });
+
+ describe('test failing status codes and verify disabling of the alertmanager', () => {
+ const isDisabledByStatusCode = (statusCode: number, expectedStatus: boolean, done: any) => {
+ service = TestBed.inject(PrometheusAlertService);
+ prometheusService = TestBed.inject(PrometheusService);
+ spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'getAlerts').and.returnValue(
+ new Observable((observer: any) => observer.error({ status: statusCode, error: {} }))
+ );
+ const disableFn = spyOn(prometheusService, 'disableAlertmanagerConfig').and.callFake(() => {
+ expect(expectedStatus).toBe(true);
+ done();
+ });
+
+ if (!expectedStatus) {
+ expect(disableFn).not.toHaveBeenCalled();
+ done();
+ }
+
+ service.getAlerts();
+ };
+
+ it('disables on 504 error which is thrown if the mgr failed', (done) => {
+ isDisabledByStatusCode(504, true, done);
+ });
+
+ it('disables on 404 error which is thrown if the external api cannot be reached', (done) => {
+ isDisabledByStatusCode(404, true, done);
+ });
+
+ it('does not disable on 400 error which is thrown if the external api receives unexpected data', (done) => {
+ isDisabledByStatusCode(400, false, done);
+ });
+ });
+
+ it('should flatten the response of getRules()', () => {
+ service = TestBed.inject(PrometheusAlertService);
+ prometheusService = TestBed.inject(PrometheusService);
+
+ spyOn(service['prometheusService'], 'ifPrometheusConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'getRules').and.returnValue(
+ of({
+ groups: [
+ {
+ name: 'group1',
+ rules: [{ name: 'nearly_full', type: 'alerting' }]
+ },
+ {
+ name: 'test',
+ rules: [
+ { name: 'load_0', type: 'alerting' },
+ { name: 'load_1', type: 'alerting' },
+ { name: 'load_2', type: 'alerting' }
+ ]
+ }
+ ]
+ })
+ );
+
+ service.getRules();
+
+ expect(service.rules as any).toEqual([
+ { name: 'nearly_full', type: 'alerting', group: 'group1' },
+ { name: 'load_0', type: 'alerting', group: 'test' },
+ { name: 'load_1', type: 'alerting', group: 'test' },
+ { name: 'load_2', type: 'alerting', group: 'test' }
+ ]);
+ });
+
+ describe('refresh', () => {
+ beforeEach(() => {
+ service = TestBed.inject(PrometheusAlertService);
+ service['alerts'] = [];
+ service['canAlertsBeNotified'] = false;
+
+ spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn());
+
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+
+ prometheusService = TestBed.inject(PrometheusService);
+ spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'getAlerts').and.callFake(() => of(alerts));
+
+ alerts = [prometheus.createAlert('alert0')];
+ service.refresh();
+ });
+
+ it('should not notify on first call', () => {
+ expect(notificationService.show).not.toHaveBeenCalled();
+ });
+
+ it('should not notify with no change', () => {
+ service.refresh();
+ expect(notificationService.show).not.toHaveBeenCalled();
+ });
+
+ it('should notify on alert change', () => {
+ alerts = [prometheus.createAlert('alert0', 'resolved')];
+ service.refresh();
+ expect(notificationService.show).toHaveBeenCalledWith(
+ new CdNotificationConfig(
+ NotificationType.success,
+ 'alert0 (resolved)',
+ 'alert0 is resolved ' + prometheus.createLink('http://alert0'),
+ undefined,
+ 'Prometheus'
+ )
+ );
+ });
+
+ it('should not notify on change to suppressed', () => {
+ alerts = [prometheus.createAlert('alert0', 'suppressed')];
+ service.refresh();
+ expect(notificationService.show).not.toHaveBeenCalled();
+ });
+
+ it('should notify on a new alert', () => {
+ alerts = [prometheus.createAlert('alert1'), prometheus.createAlert('alert0')];
+ service.refresh();
+ expect(notificationService.show).toHaveBeenCalledTimes(1);
+ expect(notificationService.show).toHaveBeenCalledWith(
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert1 (active)',
+ 'alert1 is active ' + prometheus.createLink('http://alert1'),
+ undefined,
+ 'Prometheus'
+ )
+ );
+ });
+
+ it('should notify a resolved alert if it is not there anymore', () => {
+ alerts = [];
+ service.refresh();
+ expect(notificationService.show).toHaveBeenCalledTimes(1);
+ expect(notificationService.show).toHaveBeenCalledWith(
+ new CdNotificationConfig(
+ NotificationType.success,
+ 'alert0 (resolved)',
+ 'alert0 is active ' + prometheus.createLink('http://alert0'),
+ undefined,
+ 'Prometheus'
+ )
+ );
+ });
+
+ it('should call multiple times for multiple changes', () => {
+ const alert1 = prometheus.createAlert('alert1');
+ alerts.push(alert1);
+ service.refresh();
+ alerts = [alert1, prometheus.createAlert('alert2')];
+ service.refresh();
+ expect(notificationService.show).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('alert badge', () => {
+ beforeEach(() => {
+ service = TestBed.inject(PrometheusAlertService);
+
+ prometheusService = TestBed.inject(PrometheusService);
+ spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'getAlerts').and.callFake(() => of(alerts));
+
+ alerts = [
+ prometheus.createAlert('alert0', 'active'),
+ prometheus.createAlert('alert1', 'suppressed'),
+ prometheus.createAlert('alert2', 'suppressed')
+ ];
+ service.refresh();
+ });
+
+ it('should count active alerts', () => {
+ service.refresh();
+ expect(service.activeAlerts).toBe(1);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts
new file mode 100644
index 000000000..6223808fb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts
@@ -0,0 +1,100 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+import { PrometheusService } from '../api/prometheus.service';
+import {
+ AlertmanagerAlert,
+ PrometheusCustomAlert,
+ PrometheusRule
+} from '../models/prometheus-alerts';
+import { PrometheusAlertFormatter } from './prometheus-alert-formatter';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PrometheusAlertService {
+ private canAlertsBeNotified = false;
+ alerts: AlertmanagerAlert[] = [];
+ rules: PrometheusRule[] = [];
+ activeAlerts: number;
+
+ constructor(
+ private alertFormatter: PrometheusAlertFormatter,
+ private prometheusService: PrometheusService
+ ) {}
+
+ getAlerts() {
+ this.prometheusService.ifAlertmanagerConfigured(() => {
+ this.prometheusService.getAlerts().subscribe(
+ (alerts) => this.handleAlerts(alerts),
+ (resp) => {
+ if ([404, 504].includes(resp.status)) {
+ this.prometheusService.disableAlertmanagerConfig();
+ }
+ }
+ );
+ });
+ }
+
+ getRules() {
+ this.prometheusService.ifPrometheusConfigured(() => {
+ this.prometheusService.getRules('alerting').subscribe((groups) => {
+ this.rules = groups['groups'].reduce((acc, group) => {
+ return acc.concat(
+ group.rules.map((rule) => {
+ rule.group = group.name;
+ return rule;
+ })
+ );
+ }, []);
+ });
+ });
+ }
+
+ refresh() {
+ this.getAlerts();
+ this.getRules();
+ }
+
+ private handleAlerts(alerts: AlertmanagerAlert[]) {
+ if (this.canAlertsBeNotified) {
+ this.notifyOnAlertChanges(alerts, this.alerts);
+ }
+ this.activeAlerts = _.reduce<AlertmanagerAlert, number>(
+ this.alerts,
+ (result, alert) => (alert.status.state === 'active' ? ++result : result),
+ 0
+ );
+ this.alerts = alerts;
+ this.canAlertsBeNotified = true;
+ }
+
+ private notifyOnAlertChanges(alerts: AlertmanagerAlert[], oldAlerts: AlertmanagerAlert[]) {
+ const changedAlerts = this.getChangedAlerts(
+ this.alertFormatter.convertToCustomAlerts(alerts),
+ this.alertFormatter.convertToCustomAlerts(oldAlerts)
+ );
+ const suppressedFiltered = _.filter(changedAlerts, (alert) => {
+ return alert.status !== 'suppressed';
+ });
+ const notifications = suppressedFiltered.map((alert) =>
+ this.alertFormatter.convertAlertToNotification(alert)
+ );
+ this.alertFormatter.sendNotifications(notifications);
+ }
+
+ private getChangedAlerts(alerts: PrometheusCustomAlert[], oldAlerts: PrometheusCustomAlert[]) {
+ const updatedAndNew = _.differenceWith(alerts, oldAlerts, _.isEqual);
+ return updatedAndNew.concat(this.getVanishedAlerts(alerts, oldAlerts));
+ }
+
+ private getVanishedAlerts(alerts: PrometheusCustomAlert[], oldAlerts: PrometheusCustomAlert[]) {
+ return _.differenceWith(oldAlerts, alerts, (a, b) => a.fingerprint === b.fingerprint).map(
+ (alert) => {
+ alert.status = 'resolved';
+ return alert;
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts
new file mode 100644
index 000000000..4fb2bbbb9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts
@@ -0,0 +1,227 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { ToastrModule, ToastrService } from 'ngx-toastr';
+import { of, throwError } from 'rxjs';
+
+import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper';
+import { PrometheusService } from '../api/prometheus.service';
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { AlertmanagerNotification } from '../models/prometheus-alerts';
+import { SharedModule } from '../shared.module';
+import { NotificationService } from './notification.service';
+import { PrometheusAlertFormatter } from './prometheus-alert-formatter';
+import { PrometheusNotificationService } from './prometheus-notification.service';
+
+describe('PrometheusNotificationService', () => {
+ let service: PrometheusNotificationService;
+ let notificationService: NotificationService;
+ let notifications: AlertmanagerNotification[];
+ let prometheusService: PrometheusService;
+ let prometheus: PrometheusHelper;
+ let shown: CdNotificationConfig[];
+ let getNotificationSinceMock: Function;
+
+ const toastFakeService = {
+ error: () => true,
+ info: () => true,
+ success: () => true
+ };
+
+ configureTestBed({
+ imports: [ToastrModule.forRoot(), SharedModule, HttpClientTestingModule],
+ providers: [
+ PrometheusNotificationService,
+ PrometheusAlertFormatter,
+ { provide: ToastrService, useValue: toastFakeService }
+ ]
+ });
+
+ beforeEach(() => {
+ prometheus = new PrometheusHelper();
+
+ service = TestBed.inject(PrometheusNotificationService);
+ service['notifications'] = [];
+
+ notificationService = TestBed.inject(NotificationService);
+ shown = [];
+ spyOn(notificationService, 'show').and.callThrough();
+ spyOn(notificationService, 'save').and.callFake((n) => shown.push(n));
+
+ spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn());
+
+ prometheusService = TestBed.inject(PrometheusService);
+ getNotificationSinceMock = () => of(notifications);
+ spyOn(prometheusService, 'getNotifications').and.callFake(() => getNotificationSinceMock());
+
+ notifications = [prometheus.createNotification()];
+ });
+
+ it('should create', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('getLastNotification', () => {
+ it('returns an empty object on the first call', () => {
+ service.refresh();
+ expect(prometheusService.getNotifications).toHaveBeenCalledWith(undefined);
+ expect(service['notifications'].length).toBe(1);
+ });
+
+ it('returns last notification on any other call', () => {
+ service.refresh();
+ notifications = [prometheus.createNotification(1, 'resolved')];
+ service.refresh();
+ expect(prometheusService.getNotifications).toHaveBeenCalledWith(service['notifications'][0]);
+ expect(service['notifications'].length).toBe(2);
+
+ notifications = [prometheus.createNotification(2)];
+ service.refresh();
+ notifications = [prometheus.createNotification(3, 'resolved')];
+ service.refresh();
+ expect(prometheusService.getNotifications).toHaveBeenCalledWith(service['notifications'][2]);
+ expect(service['notifications'].length).toBe(4);
+ });
+ });
+
+ it('notifies not on the first call', () => {
+ service.refresh();
+ expect(notificationService.save).not.toHaveBeenCalled();
+ });
+
+ it('notifies should not call the api again if it failed once', () => {
+ getNotificationSinceMock = () => throwError(new Error('Test error'));
+ service.refresh();
+ expect(prometheusService.getNotifications).toHaveBeenCalledTimes(1);
+ expect(service['backendFailure']).toBe(true);
+ service.refresh();
+ expect(prometheusService.getNotifications).toHaveBeenCalledTimes(1);
+ service['backendFailure'] = false;
+ });
+
+ describe('looks of fired notifications', () => {
+ const asyncRefresh = () => {
+ service.refresh();
+ tick(20);
+ };
+
+ const expectShown = (expected: object[]) => {
+ tick(500);
+ expect(shown.length).toBe(expected.length);
+ expected.forEach((e, i) =>
+ Object.keys(e).forEach((key) => expect(shown[i][key]).toEqual(expected[i][key]))
+ );
+ };
+
+ beforeEach(() => {
+ service.refresh();
+ });
+
+ it('notifies on the second call', () => {
+ service.refresh();
+ expect(notificationService.show).toHaveBeenCalledTimes(1);
+ });
+
+ it('notify looks on single notification with single alert like', fakeAsync(() => {
+ asyncRefresh();
+ expectShown([
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert0 (active)',
+ 'alert0 is firing ' + prometheus.createLink('http://alert0'),
+ undefined,
+ 'Prometheus'
+ )
+ ]);
+ }));
+
+ it('raises multiple pop overs for a single notification with multiple alerts', fakeAsync(() => {
+ asyncRefresh();
+ notifications[0].alerts.push(prometheus.createNotificationAlert('alert1', 'resolved'));
+ asyncRefresh();
+ expectShown([
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert0 (active)',
+ 'alert0 is firing ' + prometheus.createLink('http://alert0'),
+ undefined,
+ 'Prometheus'
+ ),
+ new CdNotificationConfig(
+ NotificationType.success,
+ 'alert1 (resolved)',
+ 'alert1 is resolved ' + prometheus.createLink('http://alert1'),
+ undefined,
+ 'Prometheus'
+ )
+ ]);
+ }));
+
+ it('should raise multiple notifications if they do not look like each other', fakeAsync(() => {
+ notifications[0].alerts.push(prometheus.createNotificationAlert('alert1'));
+ notifications.push(prometheus.createNotification());
+ notifications[1].alerts.push(prometheus.createNotificationAlert('alert2'));
+ asyncRefresh();
+ expectShown([
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert0 (active)',
+ 'alert0 is firing ' + prometheus.createLink('http://alert0'),
+ undefined,
+ 'Prometheus'
+ ),
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert1 (active)',
+ 'alert1 is firing ' + prometheus.createLink('http://alert1'),
+ undefined,
+ 'Prometheus'
+ ),
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert2 (active)',
+ 'alert2 is firing ' + prometheus.createLink('http://alert2'),
+ undefined,
+ 'Prometheus'
+ )
+ ]);
+ }));
+
+ it('only shows toasties if it got new data', () => {
+ service.refresh();
+ expect(notificationService.save).toHaveBeenCalledTimes(1);
+ notifications = [];
+ service.refresh();
+ service.refresh();
+ expect(notificationService.save).toHaveBeenCalledTimes(1);
+ notifications = [prometheus.createNotification()];
+ service.refresh();
+ expect(notificationService.save).toHaveBeenCalledTimes(2);
+ service.refresh();
+ expect(notificationService.save).toHaveBeenCalledTimes(3);
+ });
+
+ it('filters out duplicated and non user visible changes in notifications', fakeAsync(() => {
+ asyncRefresh();
+ // Return 2 notifications with 3 duplicated alerts and 1 non visible changed alert
+ const secondAlert = prometheus.createNotificationAlert('alert0');
+ secondAlert.endsAt = new Date().toString(); // Should be ignored as it's not visible
+ notifications[0].alerts.push(secondAlert);
+ notifications.push(prometheus.createNotification());
+ notifications[1].alerts.push(prometheus.createNotificationAlert('alert0'));
+ notifications[1].notified = 'by somebody else';
+ asyncRefresh();
+
+ expectShown([
+ new CdNotificationConfig(
+ NotificationType.error,
+ 'alert0 (active)',
+ 'alert0 is firing ' + prometheus.createLink('http://alert0'),
+ undefined,
+ 'Prometheus'
+ )
+ ]);
+ }));
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts
new file mode 100644
index 000000000..ab94c686e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts
@@ -0,0 +1,51 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+import { PrometheusService } from '../api/prometheus.service';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { AlertmanagerNotification } from '../models/prometheus-alerts';
+import { PrometheusAlertFormatter } from './prometheus-alert-formatter';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PrometheusNotificationService {
+ private notifications: AlertmanagerNotification[];
+ private backendFailure = false;
+
+ constructor(
+ private alertFormatter: PrometheusAlertFormatter,
+ private prometheusService: PrometheusService
+ ) {
+ this.notifications = [];
+ }
+
+ refresh() {
+ if (this.backendFailure) {
+ return;
+ }
+ this.prometheusService.getNotifications(_.last(this.notifications)).subscribe(
+ (notifications) => this.handleNotifications(notifications),
+ () => (this.backendFailure = true)
+ );
+ }
+
+ private handleNotifications(notifications: AlertmanagerNotification[]) {
+ if (notifications.length === 0) {
+ return;
+ }
+ if (this.notifications.length > 0) {
+ this.alertFormatter.sendNotifications(
+ _.flatten(notifications.map((notification) => this.formatNotification(notification)))
+ );
+ }
+ this.notifications = this.notifications.concat(notifications);
+ }
+
+ private formatNotification(notification: AlertmanagerNotification): CdNotificationConfig[] {
+ return this.alertFormatter
+ .convertToCustomAlerts(notification.alerts)
+ .map((alert) => this.alertFormatter.convertAlertToNotification(alert));
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts
new file mode 100644
index 000000000..92ff6baa7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts
@@ -0,0 +1,133 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper';
+import { PrometheusRule } from '../models/prometheus-alerts';
+import { SharedModule } from '../shared.module';
+import { PrometheusSilenceMatcherService } from './prometheus-silence-matcher.service';
+
+describe('PrometheusSilenceMatcherService', () => {
+ let service: PrometheusSilenceMatcherService;
+ let prometheus: PrometheusHelper;
+ let rules: PrometheusRule[];
+
+ configureTestBed({
+ imports: [SharedModule]
+ });
+
+ const addMatcher = (name: string, value: any) => ({
+ name: name,
+ value: value,
+ isRegex: false
+ });
+
+ beforeEach(() => {
+ prometheus = new PrometheusHelper();
+ service = TestBed.inject(PrometheusSilenceMatcherService);
+ rules = [
+ prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]),
+ prometheus.createRule('alert1', 'someSeverity', []),
+ prometheus.createRule('alert2', 'someOtherSeverity', [prometheus.createAlert('alert2')])
+ ];
+ });
+
+ it('should create', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('test rule matching with one matcher', () => {
+ const expectSingleMatch = (
+ name: string,
+ value: any,
+ helpText: string,
+ successClass: boolean
+ ) => {
+ const match = service.singleMatch(addMatcher(name, value), rules);
+ expect(match.status).toBe(helpText);
+ expect(match.cssClass).toBe(successClass ? 'has-success' : 'has-warning');
+ };
+
+ it('should match no rule and no alert', () => {
+ expectSingleMatch(
+ 'alertname',
+ 'alert',
+ 'Your matcher seems to match no currently defined rule or active alert.',
+ false
+ );
+ });
+
+ it('should match a rule with no alert', () => {
+ expectSingleMatch('alertname', 'alert1', 'Matches 1 rule with no active alerts.', false);
+ });
+
+ it('should match a rule and an alert', () => {
+ expectSingleMatch('alertname', 'alert0', 'Matches 1 rule with 1 active alert.', true);
+ });
+
+ it('should match multiple rules and an alert', () => {
+ expectSingleMatch('severity', 'someSeverity', 'Matches 2 rules with 1 active alert.', true);
+ });
+
+ it('should match multiple rules and multiple alerts', () => {
+ expectSingleMatch('job', 'someJob', 'Matches 2 rules with 2 active alerts.', true);
+ });
+
+ it('should return any match if regex is checked', () => {
+ const match = service.singleMatch(
+ {
+ name: 'severity',
+ value: 'someSeverity',
+ isRegex: true
+ },
+ rules
+ );
+ expect(match).toBeFalsy();
+ });
+ });
+
+ describe('test rule matching with multiple matcher', () => {
+ const expectMultiMatch = (matchers: any[], helpText: string, successClass: boolean) => {
+ const match = service.multiMatch(matchers, rules);
+ expect(match.status).toBe(helpText);
+ expect(match.cssClass).toBe(successClass ? 'has-success' : 'has-warning');
+ };
+
+ it('should match no rule and no alert', () => {
+ expectMultiMatch(
+ [addMatcher('alertname', 'alert0'), addMatcher('job', 'ceph')],
+ 'Your matcher seems to match no currently defined rule or active alert.',
+ false
+ );
+ });
+
+ it('should match a rule with no alert', () => {
+ expectMultiMatch(
+ [addMatcher('severity', 'someSeverity'), addMatcher('alertname', 'alert1')],
+ 'Matches 1 rule with no active alerts.',
+ false
+ );
+ });
+
+ it('should match a rule and an alert', () => {
+ expectMultiMatch(
+ [addMatcher('instance', 'someInstance'), addMatcher('alertname', 'alert0')],
+ 'Matches 1 rule with 1 active alert.',
+ true
+ );
+ });
+
+ it('should return any match if regex is checked', () => {
+ const match = service.multiMatch(
+ [
+ addMatcher('instance', 'someInstance'),
+ {
+ name: 'severity',
+ value: 'someSeverity',
+ isRegex: true
+ }
+ ],
+ rules
+ );
+ expect(match).toBeFalsy();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts
new file mode 100644
index 000000000..7aec6d1d3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts
@@ -0,0 +1,78 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+import {
+ AlertmanagerSilenceMatcher,
+ AlertmanagerSilenceMatcherMatch
+} from '../models/alertmanager-silence';
+import { PrometheusRule } from '../models/prometheus-alerts';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PrometheusSilenceMatcherService {
+ private valueAttributePath = {
+ alertname: 'name',
+ instance: 'alerts.0.labels.instance',
+ job: 'alerts.0.labels.job',
+ severity: 'labels.severity'
+ };
+
+ singleMatch(
+ matcher: AlertmanagerSilenceMatcher,
+ rules: PrometheusRule[]
+ ): AlertmanagerSilenceMatcherMatch {
+ return this.multiMatch([matcher], rules);
+ }
+
+ multiMatch(
+ matchers: AlertmanagerSilenceMatcher[],
+ rules: PrometheusRule[]
+ ): AlertmanagerSilenceMatcherMatch {
+ if (matchers.some((matcher) => matcher.isRegex)) {
+ return undefined;
+ }
+ matchers.forEach((matcher) => {
+ rules = this.getMatchedRules(matcher, rules);
+ });
+ return this.describeMatch(rules);
+ }
+
+ private getMatchedRules(
+ matcher: AlertmanagerSilenceMatcher,
+ rules: PrometheusRule[]
+ ): PrometheusRule[] {
+ const attributePath = this.getAttributePath(matcher.name);
+ return rules.filter((r) => _.get(r, attributePath) === matcher.value);
+ }
+
+ private describeMatch(rules: PrometheusRule[]): AlertmanagerSilenceMatcherMatch {
+ let alerts = 0;
+ rules.forEach((r) => (alerts += r.alerts.length));
+ return {
+ status: this.getMatchText(rules.length, alerts),
+ cssClass: alerts ? 'has-success' : 'has-warning'
+ };
+ }
+
+ getAttributePath(name: string): string {
+ return this.valueAttributePath[name];
+ }
+
+ private getMatchText(rules: number, alerts: number): string {
+ const msg = {
+ noRule: $localize`Your matcher seems to match no currently defined rule or active alert.`,
+ noAlerts: $localize`no active alerts`,
+ alert: $localize`1 active alert`,
+ alerts: $localize`${alerts} active alerts`,
+ rule: $localize`Matches 1 rule`,
+ rules: $localize`Matches ${rules} rules`
+ };
+
+ const rule = rules > 1 ? msg.rules : msg.rule;
+ const alert = alerts ? (alerts > 1 ? msg.alerts : msg.alert) : msg.noAlerts;
+
+ return rules ? $localize`${rule} with ${alert}.` : msg.noRule;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts
new file mode 100644
index 000000000..b119f5d63
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts
@@ -0,0 +1,45 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdConfigurationType } from '../models/configuration';
+import { RbdConfigurationService } from './rbd-configuration.service';
+
+describe('RbdConfigurationService', () => {
+ let service: RbdConfigurationService;
+
+ configureTestBed({
+ providers: [RbdConfigurationService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RbdConfigurationService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should filter config options', () => {
+ const result = service.getOptionByName('rbd_qos_write_iops_burst');
+ expect(result).toEqual({
+ name: 'rbd_qos_write_iops_burst',
+ displayName: 'Write IOPS Burst',
+ description: 'The desired burst limit of write operations.',
+ type: RbdConfigurationType.iops
+ });
+ });
+
+ it('should return the display name', () => {
+ const displayName = service.getDisplayName('rbd_qos_write_iops_burst');
+ expect(displayName).toBe('Write IOPS Burst');
+ });
+
+ it('should return the description', () => {
+ const description = service.getDescription('rbd_qos_write_iops_burst');
+ expect(description).toBe('The desired burst limit of write operations.');
+ });
+
+ it('should have a class for each section', () => {
+ service.sections.forEach((section) => expect(section.class).toBeTruthy());
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts
new file mode 100644
index 000000000..4499718e1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts
@@ -0,0 +1,144 @@
+import { Injectable } from '@angular/core';
+
+import {
+ RbdConfigurationExtraField,
+ RbdConfigurationSection,
+ RbdConfigurationType
+} from '../models/configuration';
+
+/**
+ * Define here which options should be made available under which section heading.
+ * The display name and description needs to be added manually as long as Ceph does not provide
+ * this information.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class RbdConfigurationService {
+ readonly sections: RbdConfigurationSection[];
+
+ constructor() {
+ this.sections = [
+ {
+ heading: $localize`Quality of Service`,
+ class: 'quality-of-service',
+ options: [
+ {
+ name: 'rbd_qos_bps_limit',
+ displayName: $localize`BPS Limit`,
+ description: $localize`The desired limit of IO bytes per second.`,
+ type: RbdConfigurationType.bps
+ },
+ {
+ name: 'rbd_qos_iops_limit',
+ displayName: $localize`IOPS Limit`,
+ description: $localize`The desired limit of IO operations per second.`,
+ type: RbdConfigurationType.iops
+ },
+ {
+ name: 'rbd_qos_read_bps_limit',
+ displayName: $localize`Read BPS Limit`,
+ description: $localize`The desired limit of read bytes per second.`,
+ type: RbdConfigurationType.bps
+ },
+ {
+ name: 'rbd_qos_read_iops_limit',
+ displayName: $localize`Read IOPS Limit`,
+ description: $localize`The desired limit of read operations per second.`,
+ type: RbdConfigurationType.iops
+ },
+ {
+ name: 'rbd_qos_write_bps_limit',
+ displayName: $localize`Write BPS Limit`,
+ description: $localize`The desired limit of write bytes per second.`,
+ type: RbdConfigurationType.bps
+ },
+ {
+ name: 'rbd_qos_write_iops_limit',
+ displayName: $localize`Write IOPS Limit`,
+ description: $localize`The desired limit of write operations per second.`,
+ type: RbdConfigurationType.iops
+ },
+ {
+ name: 'rbd_qos_bps_burst',
+ displayName: $localize`BPS Burst`,
+ description: $localize`The desired burst limit of IO bytes.`,
+ type: RbdConfigurationType.bps
+ },
+ {
+ name: 'rbd_qos_iops_burst',
+ displayName: $localize`IOPS Burst`,
+ description: $localize`The desired burst limit of IO operations.`,
+ type: RbdConfigurationType.iops
+ },
+ {
+ name: 'rbd_qos_read_bps_burst',
+ displayName: $localize`Read BPS Burst`,
+ description: $localize`The desired burst limit of read bytes.`,
+ type: RbdConfigurationType.bps
+ },
+ {
+ name: 'rbd_qos_read_iops_burst',
+ displayName: $localize`Read IOPS Burst`,
+ description: $localize`The desired burst limit of read operations.`,
+ type: RbdConfigurationType.iops
+ },
+ {
+ name: 'rbd_qos_write_bps_burst',
+ displayName: $localize`Write BPS Burst`,
+ description: $localize`The desired burst limit of write bytes.`,
+ type: RbdConfigurationType.bps
+ },
+ {
+ name: 'rbd_qos_write_iops_burst',
+ displayName: $localize`Write IOPS Burst`,
+ description: $localize`The desired burst limit of write operations.`,
+ type: RbdConfigurationType.iops
+ }
+ ] as RbdConfigurationExtraField[]
+ }
+ ];
+ }
+
+ private static getOptionsFromSections(sections: RbdConfigurationSection[]) {
+ return sections.map((section) => section.options).reduce((a, b) => a.concat(b));
+ }
+
+ private filterConfigOptionsByName(configName: string) {
+ return RbdConfigurationService.getOptionsFromSections(this.sections).filter(
+ (option) => option.name === configName
+ );
+ }
+
+ private getOptionValueByName(configName: string, fieldName: string, defaultValue = '') {
+ const configOptions = this.filterConfigOptionsByName(configName);
+ return configOptions.length === 1 ? configOptions.pop()[fieldName] : defaultValue;
+ }
+
+ getWritableSections() {
+ return this.sections.map((section) => {
+ section.options = section.options.filter((o) => !o.readOnly);
+ return section;
+ });
+ }
+
+ getOptionFields() {
+ return RbdConfigurationService.getOptionsFromSections(this.sections);
+ }
+
+ getWritableOptionFields() {
+ return RbdConfigurationService.getOptionsFromSections(this.getWritableSections());
+ }
+
+ getOptionByName(optionName: string): RbdConfigurationExtraField {
+ return this.filterConfigOptionsByName(optionName).pop();
+ }
+
+ getDisplayName(configName: string): string {
+ return this.getOptionValueByName(configName, 'displayName');
+ }
+
+ getDescription(configName: string): string {
+ return this.getOptionValueByName(configName, 'description');
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts
new file mode 100644
index 000000000..c26d6389b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts
@@ -0,0 +1,52 @@
+import { NgZone } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RefreshIntervalService } from './refresh-interval.service';
+
+describe('RefreshIntervalService', () => {
+ let service: RefreshIntervalService;
+
+ configureTestBed({
+ providers: [RefreshIntervalService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(RefreshIntervalService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should initial private interval time right', () => {
+ sessionStorage.setItem('dashboard_interval', '10000');
+ const ngZone = TestBed.inject(NgZone);
+ service = new RefreshIntervalService(ngZone);
+ expect(service.getRefreshInterval()).toBe(10000);
+ });
+
+ describe('setRefreshInterval', () => {
+ let notifyCount: number;
+
+ it('should send notification to component at correct interval time when interval changed', fakeAsync(() => {
+ service.intervalData$.subscribe(() => {
+ notifyCount++;
+ });
+
+ notifyCount = 0;
+ service.setRefreshInterval(10000);
+ tick(10000);
+ expect(service.getRefreshInterval()).toBe(10000);
+ expect(notifyCount).toBe(1);
+
+ notifyCount = 0;
+ service.setRefreshInterval(30000);
+ tick(30000);
+ expect(service.getRefreshInterval()).toBe(30000);
+ expect(notifyCount).toBe(1);
+
+ service.ngOnDestroy();
+ }));
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts
new file mode 100644
index 000000000..03aa3b8a5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts
@@ -0,0 +1,46 @@
+import { Injectable, NgZone, OnDestroy } from '@angular/core';
+
+import { BehaviorSubject, interval, Subscription } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class RefreshIntervalService implements OnDestroy {
+ private intervalTime: number;
+ // Observable sources
+ private intervalDataSource = new BehaviorSubject(null);
+ private intervalSubscription: Subscription;
+ // Observable streams
+ intervalData$ = this.intervalDataSource.asObservable();
+
+ constructor(private ngZone: NgZone) {
+ const initialInterval = parseInt(sessionStorage.getItem('dashboard_interval'), 10) || 5000;
+ this.setRefreshInterval(initialInterval);
+ }
+
+ setRefreshInterval(newInterval: number) {
+ this.intervalTime = newInterval;
+ sessionStorage.setItem('dashboard_interval', newInterval.toString());
+
+ if (this.intervalSubscription) {
+ this.intervalSubscription.unsubscribe();
+ }
+ this.ngZone.runOutsideAngular(() => {
+ this.intervalSubscription = interval(this.intervalTime).subscribe(() =>
+ this.ngZone.run(() => {
+ this.intervalDataSource.next(this.intervalTime);
+ })
+ );
+ });
+ }
+
+ getRefreshInterval() {
+ return this.intervalTime;
+ }
+
+ ngOnDestroy() {
+ if (this.intervalSubscription) {
+ this.intervalSubscription.unsubscribe();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts
new file mode 100644
index 000000000..5369a578d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts
@@ -0,0 +1,179 @@
+import { HttpClient } from '@angular/common/http';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of as observableOf, Subscriber, Subscription } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ExecutingTask } from '../models/executing-task';
+import { Summary } from '../models/summary.model';
+import { AuthStorageService } from './auth-storage.service';
+import { SummaryService } from './summary.service';
+
+describe('SummaryService', () => {
+ let summaryService: SummaryService;
+ let authStorageService: AuthStorageService;
+ let subs: Subscription;
+
+ const summary: Summary = {
+ executing_tasks: [],
+ health_status: 'HEALTH_OK',
+ mgr_id: 'x',
+ rbd_mirroring: { errors: 0, warnings: 0 },
+ rbd_pools: [],
+ have_mon_connection: true,
+ finished_tasks: [],
+ filesystems: [{ id: 1, name: 'cephfs_a' }]
+ };
+
+ const httpClientSpy = {
+ get: () => observableOf(summary)
+ };
+
+ const nextSummary = (newData: any) => summaryService['summaryDataSource'].next(newData);
+
+ configureTestBed({
+ imports: [RouterTestingModule],
+ providers: [
+ SummaryService,
+ AuthStorageService,
+ { provide: HttpClient, useValue: httpClientSpy }
+ ]
+ });
+
+ beforeEach(() => {
+ summaryService = TestBed.inject(SummaryService);
+ authStorageService = TestBed.inject(AuthStorageService);
+ });
+
+ it('should be created', () => {
+ expect(summaryService).toBeTruthy();
+ });
+
+ it('should call refresh', fakeAsync(() => {
+ authStorageService.set('foobar', undefined, undefined);
+ const calledWith: any[] = [];
+ subs = new Subscription();
+ subs.add(summaryService.startPolling());
+ tick();
+ subs.add(
+ summaryService.subscribe((data) => {
+ calledWith.push(data);
+ })
+ );
+ expect(calledWith).toEqual([summary]);
+ subs.add(summaryService.refresh());
+ expect(calledWith).toEqual([summary, summary]);
+ tick(summaryService.REFRESH_INTERVAL * 2);
+ expect(calledWith.length).toEqual(4);
+ subs.unsubscribe();
+ }));
+
+ describe('Should test subscribe without initial value', () => {
+ let result: Summary;
+ let i: number;
+
+ const callback = (response: Summary) => {
+ i++;
+ result = response;
+ };
+
+ beforeEach(() => {
+ i = 0;
+ result = undefined;
+ nextSummary(undefined);
+ });
+
+ it('should call subscribeOnce', () => {
+ const subscriber = summaryService.subscribeOnce(callback);
+
+ expect(subscriber).toEqual(jasmine.any(Subscriber));
+ expect(i).toBe(0);
+ expect(result).toEqual(undefined);
+
+ nextSummary(undefined);
+ expect(i).toBe(0);
+ expect(result).toEqual(undefined);
+ expect(subscriber.closed).toBe(false);
+
+ nextSummary(summary);
+ expect(result).toEqual(summary);
+ expect(i).toBe(1);
+ expect(subscriber.closed).toBe(true);
+
+ nextSummary(summary);
+ expect(result).toEqual(summary);
+ expect(i).toBe(1);
+ });
+
+ it('should call subscribe', () => {
+ const subscriber = summaryService.subscribe(callback);
+
+ expect(subscriber).toEqual(jasmine.any(Subscriber));
+ expect(i).toBe(0);
+ expect(result).toEqual(undefined);
+
+ nextSummary(undefined);
+ expect(i).toBe(0);
+ expect(result).toEqual(undefined);
+ expect(subscriber.closed).toBe(false);
+
+ nextSummary(summary);
+ expect(result).toEqual(summary);
+ expect(i).toBe(1);
+ expect(subscriber.closed).toBe(false);
+
+ nextSummary(summary);
+ expect(result).toEqual(summary);
+ expect(i).toBe(2);
+ expect(subscriber.closed).toBe(false);
+ });
+ });
+
+ describe('Should test methods after first refresh', () => {
+ beforeEach(() => {
+ authStorageService.set('foobar', undefined, undefined);
+ summaryService.refresh();
+ });
+
+ it('should call addRunningTask', () => {
+ summaryService.addRunningTask(
+ new ExecutingTask('rbd/delete', {
+ pool_name: 'somePool',
+ image_name: 'someImage'
+ })
+ );
+ let result: any;
+ summaryService.subscribeOnce((response) => {
+ result = response;
+ });
+
+ expect(result.executing_tasks.length).toBe(1);
+ expect(result.executing_tasks[0]).toEqual({
+ metadata: { image_name: 'someImage', pool_name: 'somePool' },
+ name: 'rbd/delete'
+ });
+ });
+
+ it('should call addRunningTask with duplicate task', () => {
+ let result: any;
+ summaryService.subscribe((response) => {
+ result = response;
+ });
+
+ const exec_task = new ExecutingTask('rbd/delete', {
+ pool_name: 'somePool',
+ image_name: 'someImage'
+ });
+
+ result.executing_tasks = [exec_task];
+ nextSummary(result);
+
+ expect(result.executing_tasks.length).toBe(1);
+
+ summaryService.addRunningTask(exec_task);
+
+ expect(result.executing_tasks.length).toBe(1);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts
new file mode 100644
index 000000000..f8282ae97
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts
@@ -0,0 +1,89 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { filter, first } from 'rxjs/operators';
+
+import { ExecutingTask } from '../models/executing-task';
+import { Summary } from '../models/summary.model';
+import { TimerService } from './timer.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class SummaryService {
+ readonly REFRESH_INTERVAL = 5000;
+ // Observable sources
+ private summaryDataSource = new BehaviorSubject<Summary>(null);
+ // Observable streams
+ summaryData$ = this.summaryDataSource.asObservable();
+
+ constructor(private http: HttpClient, private timerService: TimerService) {}
+
+ startPolling(): Subscription {
+ return this.timerService
+ .get(() => this.retrieveSummaryObservable(), this.REFRESH_INTERVAL)
+ .subscribe(this.retrieveSummaryObserver());
+ }
+
+ refresh(): Subscription {
+ return this.retrieveSummaryObservable().subscribe(this.retrieveSummaryObserver());
+ }
+
+ private retrieveSummaryObservable(): Observable<Summary> {
+ return this.http.get<Summary>('api/summary');
+ }
+
+ private retrieveSummaryObserver(): (data: Summary) => void {
+ return (data: Summary) => {
+ this.summaryDataSource.next(data);
+ };
+ }
+
+ /**
+ * Subscribes to the summaryData and receive only the first, non undefined, value.
+ */
+ subscribeOnce(next: (summary: Summary) => void, error?: (error: any) => void): Subscription {
+ return this.summaryData$
+ .pipe(
+ filter((value) => !!value),
+ first()
+ )
+ .subscribe(next, error);
+ }
+
+ /**
+ * Subscribes to the summaryData,
+ * which is updated periodically or when a new task is created.
+ * Will receive only non undefined values.
+ */
+ subscribe(next: (summary: Summary) => void, error?: (error: any) => void): Subscription {
+ return this.summaryData$.pipe(filter((value) => !!value)).subscribe(next, error);
+ }
+
+ /**
+ * Inserts a newly created task to the local list of executing tasks.
+ * After that, it will automatically push that new information
+ * to all subscribers.
+ */
+ addRunningTask(task: ExecutingTask) {
+ const current = this.summaryDataSource.getValue();
+ if (!current) {
+ return;
+ }
+
+ if (_.isArray(current.executing_tasks)) {
+ const exists = current.executing_tasks.find((element: any) => {
+ return element.name === task.name && _.isEqual(element.metadata, task.metadata);
+ });
+ if (!exists) {
+ current.executing_tasks.push(task);
+ }
+ } else {
+ current.executing_tasks = [task];
+ }
+
+ this.summaryDataSource.next(current);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts
new file mode 100644
index 000000000..66aad3cff
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts
@@ -0,0 +1,133 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+import { configureTestBed, expectItemTasks } from '~/testing/unit-test-helper';
+import { RbdService } from '../api/rbd.service';
+import { ExecutingTask } from '../models/executing-task';
+import { SummaryService } from './summary.service';
+import { TaskListService } from './task-list.service';
+import { TaskMessageService } from './task-message.service';
+
+describe('TaskListService', () => {
+ let service: TaskListService;
+ let summaryService: SummaryService;
+ let taskMessageService: TaskMessageService;
+
+ let list: any[];
+ let apiResp: any;
+ let tasks: any[];
+
+ const addItem = (name: string) => {
+ apiResp.push({ name: name });
+ };
+
+ configureTestBed({
+ providers: [TaskListService, TaskMessageService, SummaryService, RbdService],
+ imports: [HttpClientTestingModule, RouterTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TaskListService);
+ summaryService = TestBed.inject(SummaryService);
+ taskMessageService = TestBed.inject(TaskMessageService);
+ summaryService['summaryDataSource'].next({ executing_tasks: [] });
+
+ taskMessageService.messages['test/create'] = taskMessageService.messages['rbd/create'];
+ taskMessageService.messages['test/edit'] = taskMessageService.messages['rbd/edit'];
+ taskMessageService.messages['test/delete'] = taskMessageService.messages['rbd/delete'];
+
+ tasks = [];
+ apiResp = [];
+ list = [];
+ addItem('a');
+ addItem('b');
+ addItem('c');
+
+ service.init(
+ () => of(apiResp),
+ undefined,
+ (updatedList) => (list = updatedList),
+ () => true,
+ (task) => task.name.startsWith('test'),
+ (item, task) => item.name === task.metadata['name'],
+ {
+ default: (metadata: object) => ({ name: metadata['name'] })
+ }
+ );
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ const addTask = (name: string, itemName: string, progress?: number) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ task.progress = progress;
+ task.metadata = { name: itemName };
+ tasks.push(task);
+ summaryService.addRunningTask(task);
+ };
+
+ it('gets all items without any executing items', () => {
+ expect(list.length).toBe(3);
+ expect(list.every((item) => !item.cdExecuting)).toBeTruthy();
+ });
+
+ it('gets an item from a task during creation', () => {
+ addTask('test/create', 'd');
+ expect(list.length).toBe(4);
+ expectItemTasks(list[3], 'Creating');
+ });
+
+ it('shows progress of current task if any above 0', () => {
+ addTask('test/edit', 'd', 97);
+ addTask('test/edit', 'e', 0);
+ expect(list.length).toBe(5);
+ expectItemTasks(list[3], 'Updating', 97);
+ expectItemTasks(list[4], 'Updating');
+ });
+
+ it('gets all items with one executing items', () => {
+ addTask('test/create', 'a');
+ expect(list.length).toBe(3);
+ expectItemTasks(list[0], 'Creating');
+ expectItemTasks(list[1], undefined);
+ expectItemTasks(list[2], undefined);
+ });
+
+ it('gets all items with multiple executing items', () => {
+ addTask('test/create', 'a');
+ addTask('test/edit', 'a');
+ addTask('test/delete', 'a');
+ addTask('test/edit', 'b');
+ addTask('test/delete', 'b');
+ addTask('test/delete', 'c');
+ expect(list.length).toBe(3);
+ expectItemTasks(list[0], 'Creating..., Updating..., Deleting');
+ expectItemTasks(list[1], 'Updating..., Deleting');
+ expectItemTasks(list[2], 'Deleting');
+ });
+
+ it('gets all items with multiple executing tasks (not only item tasks', () => {
+ addTask('rbd/create', 'a');
+ addTask('rbd/edit', 'a');
+ addTask('test/delete', 'a');
+ addTask('test/edit', 'b');
+ addTask('rbd/delete', 'b');
+ addTask('rbd/delete', 'c');
+ expect(list.length).toBe(3);
+ expectItemTasks(list[0], 'Deleting');
+ expectItemTasks(list[1], 'Updating');
+ expectItemTasks(list[2], undefined);
+ });
+
+ it('should call ngOnDestroy', () => {
+ expect(service.summaryDataSubscription.closed).toBeFalsy();
+ service.ngOnDestroy();
+ expect(service.summaryDataSubscription.closed).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts
new file mode 100644
index 000000000..321454753
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts
@@ -0,0 +1,111 @@
+import { Injectable, OnDestroy } from '@angular/core';
+
+import { Observable, Subscription } from 'rxjs';
+
+import { ExecutingTask } from '../models/executing-task';
+import { Summary } from '../models/summary.model';
+import { SummaryService } from './summary.service';
+import { TaskMessageService } from './task-message.service';
+
+@Injectable()
+export class TaskListService implements OnDestroy {
+ summaryDataSubscription: Subscription;
+
+ getUpdate: (context?: any) => Observable<object>;
+ preProcessing: (_: any) => any[];
+ setList: (_: any[]) => void;
+ onFetchError: (error: any) => void;
+ taskFilter: (task: ExecutingTask) => boolean;
+ itemFilter: (item: any, task: ExecutingTask) => boolean;
+ builders: object;
+ summary: Summary;
+
+ constructor(
+ private taskMessageService: TaskMessageService,
+ private summaryService: SummaryService
+ ) {}
+
+ /**
+ * @param {() => Observable<object>} getUpdate Method that calls the api and
+ * returns that without subscribing.
+ * @param {(_: any) => any[]} preProcessing Method executed before merging
+ * Tasks with Items
+ * @param {(_: any[]) => void} setList Method used to update array of item in the component.
+ * @param {(error: any) => void} onFetchError Method called when there were
+ * problems while fetching data.
+ * @param {(task: ExecutingTask) => boolean} taskFilter callback used in tasks_array.filter()
+ * @param {(item, task: ExecutingTask) => boolean} itemFilter callback used in
+ * items_array.filter()
+ * @param {object} builders
+ * object with builders for each type of task.
+ * You can also use a 'default' one.
+ * @memberof TaskListService
+ */
+ init(
+ getUpdate: (context?: any) => Observable<object>,
+ preProcessing: (_: any) => any[],
+ setList: (_: any[]) => void,
+ onFetchError: (error: any) => void,
+ taskFilter: (task: ExecutingTask) => boolean,
+ itemFilter: (item: any, task: ExecutingTask) => boolean,
+ builders: object
+ ) {
+ this.getUpdate = getUpdate;
+ this.preProcessing = preProcessing;
+ this.setList = setList;
+ this.onFetchError = onFetchError;
+ this.taskFilter = taskFilter;
+ this.itemFilter = itemFilter;
+ this.builders = builders || {};
+
+ this.summaryDataSubscription = this.summaryService.subscribe((summary) => {
+ this.summary = summary;
+ this.fetch();
+ }, this.onFetchError);
+ }
+
+ fetch(context: any = null) {
+ this.getUpdate(context).subscribe((resp: any) => {
+ this.updateData(resp, this.summary?.['executing_tasks'].filter(this.taskFilter));
+ }, this.onFetchError);
+ }
+
+ private updateData(resp: any, tasks: ExecutingTask[]) {
+ const data: any[] = this.preProcessing ? this.preProcessing(resp) : resp;
+ this.addMissing(data, tasks);
+ data.forEach((item) => {
+ const executingTasks = tasks.filter((task) => this.itemFilter(item, task));
+ item.cdExecuting = this.getTaskAction(executingTasks);
+ });
+ this.setList(data);
+ }
+
+ private addMissing(data: any[], tasks: ExecutingTask[]) {
+ const defaultBuilder = this.builders['default'];
+ tasks?.forEach((task) => {
+ const existing = data.find((item) => this.itemFilter(item, task));
+ const builder = this.builders[task.name];
+ if (!existing && (builder || defaultBuilder)) {
+ data.push(builder ? builder(task.metadata) : defaultBuilder(task.metadata));
+ }
+ });
+ }
+
+ private getTaskAction(tasks: ExecutingTask[]): string {
+ if (tasks.length === 0) {
+ return undefined;
+ }
+ return tasks
+ .map((task) => {
+ const progress = task.progress ? ` ${task.progress}%` : '';
+ return this.taskMessageService.getRunningText(task) + '...' + progress;
+ })
+ .join(', ');
+ }
+
+ ngOnDestroy() {
+ if (this.summaryDataSubscription) {
+ this.summaryDataSubscription.unsubscribe();
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts
new file mode 100644
index 000000000..117b60c7e
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts
@@ -0,0 +1,72 @@
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import _ from 'lodash';
+import { BehaviorSubject } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SummaryService } from './summary.service';
+import { TaskManagerService } from './task-manager.service';
+
+const summary: Record<string, any> = {
+ executing_tasks: [],
+ health_status: 'HEALTH_OK',
+ mgr_id: 'x',
+ rbd_mirroring: { errors: 0, warnings: 0 },
+ rbd_pools: [],
+ have_mon_connection: true,
+ finished_tasks: [{ name: 'foo', metadata: {} }],
+ filesystems: [{ id: 1, name: 'cephfs_a' }]
+};
+
+export class SummaryServiceMock {
+ summaryDataSource = new BehaviorSubject(summary);
+ summaryData$ = this.summaryDataSource.asObservable();
+
+ refresh() {
+ this.summaryDataSource.next(summary);
+ }
+ subscribe(call: any) {
+ return this.summaryData$.subscribe(call);
+ }
+}
+
+describe('TaskManagerService', () => {
+ let taskManagerService: TaskManagerService;
+ let summaryService: any;
+ let called: boolean;
+
+ configureTestBed({
+ providers: [TaskManagerService, { provide: SummaryService, useClass: SummaryServiceMock }]
+ });
+
+ beforeEach(() => {
+ taskManagerService = TestBed.inject(TaskManagerService);
+ summaryService = TestBed.inject(SummaryService);
+ called = false;
+ taskManagerService.subscribe('foo', {}, () => (called = true));
+ });
+
+ it('should be created', () => {
+ expect(taskManagerService).toBeTruthy();
+ });
+
+ it('should subscribe and be notified when task is finished', fakeAsync(() => {
+ expect(taskManagerService.subscriptions.length).toBe(1);
+ summaryService.refresh();
+ tick();
+ taskManagerService.init(summaryService);
+ expect(called).toEqual(true);
+ expect(taskManagerService.subscriptions).toEqual([]);
+ }));
+
+ it('should subscribe and process executing taks', fakeAsync(() => {
+ const original_subscriptions = _.cloneDeep(taskManagerService.subscriptions);
+ _.assign(summary, {
+ executing_tasks: [{ name: 'foo', metadata: {} }],
+ finished_tasks: []
+ });
+ summaryService.refresh();
+ tick();
+ expect(taskManagerService.subscriptions).toEqual(original_subscriptions);
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts
new file mode 100644
index 000000000..0310a7826
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts
@@ -0,0 +1,59 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+import { ExecutingTask } from '../models/executing-task';
+import { FinishedTask } from '../models/finished-task';
+import { Task } from '../models/task';
+import { SummaryService } from './summary.service';
+
+class TaskSubscription {
+ name: string;
+ metadata: object;
+ onTaskFinished: (finishedTask: FinishedTask) => any;
+
+ constructor(name: string, metadata: object, onTaskFinished: any) {
+ this.name = name;
+ this.metadata = metadata;
+ this.onTaskFinished = onTaskFinished;
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TaskManagerService {
+ subscriptions: Array<TaskSubscription> = [];
+
+ init(summaryService: SummaryService) {
+ return summaryService.subscribe((summary) => {
+ const executingTasks = summary.executing_tasks;
+ const finishedTasks = summary.finished_tasks;
+ const newSubscriptions: Array<TaskSubscription> = [];
+ for (const subscription of this.subscriptions) {
+ const finishedTask = <FinishedTask>this._getTask(subscription, finishedTasks);
+ const executingTask = <ExecutingTask>this._getTask(subscription, executingTasks);
+ if (finishedTask !== null && executingTask === null) {
+ subscription.onTaskFinished(finishedTask);
+ }
+ if (executingTask !== null) {
+ newSubscriptions.push(subscription);
+ }
+ this.subscriptions = newSubscriptions;
+ }
+ });
+ }
+
+ subscribe(name: string, metadata: object, onTaskFinished: (finishedTask: FinishedTask) => any) {
+ this.subscriptions.push(new TaskSubscription(name, metadata, onTaskFinished));
+ }
+
+ private _getTask(subscription: TaskSubscription, tasks: Array<Task>): Task {
+ for (const task of tasks) {
+ if (task.name === subscription.name && _.isEqual(task.metadata, subscription.metadata)) {
+ return task;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
new file mode 100644
index 000000000..a529656a0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
@@ -0,0 +1,312 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import _ from 'lodash';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RbdService } from '../api/rbd.service';
+import { FinishedTask } from '../models/finished-task';
+import { TaskException } from '../models/task-exception';
+import { TaskMessageOperation, TaskMessageService } from './task-message.service';
+
+describe('TaskManagerMessageService', () => {
+ let service: TaskMessageService;
+ let finishedTask: FinishedTask;
+
+ configureTestBed({
+ providers: [TaskMessageService, RbdService],
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TaskMessageService);
+ finishedTask = new FinishedTask();
+ finishedTask.duration = 30;
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should get default description', () => {
+ expect(service.getErrorTitle(finishedTask)).toBe('Failed to execute unknown task');
+ });
+
+ it('should get default running message', () => {
+ expect(service.getRunningTitle(finishedTask)).toBe('Executing unknown task');
+ });
+
+ it('should get default running message with a set component', () => {
+ finishedTask.metadata = { component: 'rbd' };
+ expect(service.getRunningTitle(finishedTask)).toBe('Executing RBD');
+ });
+
+ it('should getSuccessMessage', () => {
+ expect(service.getSuccessTitle(finishedTask)).toBe('Executed unknown task');
+ });
+
+ describe('defined tasks messages', () => {
+ let defaultMsg: string;
+ const testMessages = (operation: TaskMessageOperation, involves: string) => {
+ expect(service.getRunningTitle(finishedTask)).toBe(operation.running + ' ' + involves);
+ expect(service.getErrorTitle(finishedTask)).toBe(
+ 'Failed to ' + operation.failure + ' ' + involves
+ );
+ expect(service.getSuccessTitle(finishedTask)).toBe(`${operation.success} ${involves}`);
+ };
+
+ const testCreate = (involves: string) => {
+ testMessages(new TaskMessageOperation('Creating', 'create', 'Created'), involves);
+ };
+
+ const testUpdate = (involves: string) => {
+ testMessages(new TaskMessageOperation('Updating', 'update', 'Updated'), involves);
+ };
+
+ const testDelete = (involves: string) => {
+ testMessages(new TaskMessageOperation('Deleting', 'delete', 'Deleted'), involves);
+ };
+
+ const testImport = (involves: string) => {
+ testMessages(new TaskMessageOperation('Importing', 'import', 'Imported'), involves);
+ };
+
+ const testErrorCode = (code: number, msg: string) => {
+ finishedTask.exception = _.assign(new TaskException(), {
+ code: code
+ });
+ expect(service.getErrorMessage(finishedTask)).toBe(msg);
+ };
+
+ describe('pool tasks', () => {
+ beforeEach(() => {
+ const metadata = {
+ pool_name: 'somePool'
+ };
+ defaultMsg = `pool '${metadata.pool_name}'`;
+ finishedTask.metadata = metadata;
+ });
+
+ it('tests pool/create messages', () => {
+ finishedTask.name = 'pool/create';
+ testCreate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests pool/edit messages', () => {
+ finishedTask.name = 'pool/edit';
+ testUpdate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests pool/delete messages', () => {
+ finishedTask.name = 'pool/delete';
+ testDelete(defaultMsg);
+ });
+ });
+
+ describe('erasure code profile tasks', () => {
+ beforeEach(() => {
+ const metadata = {
+ name: 'someEcpName'
+ };
+ defaultMsg = `erasure code profile '${metadata.name}'`;
+ finishedTask.metadata = metadata;
+ });
+
+ it('tests ecp/create messages', () => {
+ finishedTask.name = 'ecp/create';
+ testCreate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests ecp/delete messages', () => {
+ finishedTask.name = 'ecp/delete';
+ testDelete(defaultMsg);
+ });
+ });
+
+ describe('crush rule tasks', () => {
+ beforeEach(() => {
+ const metadata = {
+ name: 'someRuleName'
+ };
+ defaultMsg = `crush rule '${metadata.name}'`;
+ finishedTask.metadata = metadata;
+ });
+
+ it('tests crushRule/create messages', () => {
+ finishedTask.name = 'crushRule/create';
+ testCreate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests crushRule/delete messages', () => {
+ finishedTask.name = 'crushRule/delete';
+ testDelete(defaultMsg);
+ });
+ });
+
+ describe('rbd tasks', () => {
+ let metadata: Record<string, any>;
+ let childMsg: string;
+ let destinationMsg: string;
+ let snapMsg: string;
+
+ beforeEach(() => {
+ metadata = {
+ pool_name: 'somePool',
+ image_name: 'someImage',
+ image_id: '12345',
+ image_spec: 'somePool/someImage',
+ image_id_spec: 'somePool/12345',
+ snapshot_name: 'someSnapShot',
+ dest_pool_name: 'someDestinationPool',
+ dest_image_name: 'someDestinationImage',
+ child_pool_name: 'someChildPool',
+ child_image_name: 'someChildImage',
+ new_image_name: 'someImage2'
+ };
+ defaultMsg = `RBD '${metadata.pool_name}/${metadata.image_name}'`;
+ childMsg = `RBD '${metadata.child_pool_name}/${metadata.child_image_name}'`;
+ destinationMsg = `RBD '${metadata.dest_pool_name}/${metadata.dest_image_name}'`;
+ snapMsg = `RBD snapshot '${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}'`;
+ finishedTask.metadata = metadata;
+ });
+
+ it('tests rbd/create messages', () => {
+ finishedTask.name = 'rbd/create';
+ testCreate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests rbd/edit messages', () => {
+ finishedTask.name = 'rbd/edit';
+ testUpdate(defaultMsg);
+ testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+ });
+
+ it('tests rbd/delete messages', () => {
+ finishedTask.name = 'rbd/delete';
+ testDelete(defaultMsg);
+ testErrorCode(16, `${defaultMsg} is busy.`);
+ testErrorCode(39, `${defaultMsg} contains snapshots.`);
+ });
+
+ it('tests rbd/clone messages', () => {
+ finishedTask.name = 'rbd/clone';
+ testMessages(new TaskMessageOperation('Cloning', 'clone', 'Cloned'), childMsg);
+ testErrorCode(17, `Name is already used by ${childMsg}.`);
+ testErrorCode(22, `Snapshot of ${childMsg} must be protected.`);
+ });
+
+ it('tests rbd/copy messages', () => {
+ finishedTask.name = 'rbd/copy';
+ testMessages(new TaskMessageOperation('Copying', 'copy', 'Copied'), destinationMsg);
+ testErrorCode(17, `Name is already used by ${destinationMsg}.`);
+ });
+
+ it('tests rbd/flatten messages', () => {
+ finishedTask.name = 'rbd/flatten';
+ testMessages(new TaskMessageOperation('Flattening', 'flatten', 'Flattened'), defaultMsg);
+ });
+
+ it('tests rbd/snap/create messages', () => {
+ finishedTask.name = 'rbd/snap/create';
+ testCreate(snapMsg);
+ testErrorCode(17, `Name is already used by ${snapMsg}.`);
+ });
+
+ it('tests rbd/snap/edit messages', () => {
+ finishedTask.name = 'rbd/snap/edit';
+ testUpdate(snapMsg);
+ testErrorCode(16, `Cannot unprotect ${snapMsg} because it contains child images.`);
+ });
+
+ it('tests rbd/snap/delete messages', () => {
+ finishedTask.name = 'rbd/snap/delete';
+ testDelete(snapMsg);
+ testErrorCode(16, `Cannot delete ${snapMsg} because it's protected.`);
+ });
+
+ it('tests rbd/snap/rollback messages', () => {
+ finishedTask.name = 'rbd/snap/rollback';
+ testMessages(new TaskMessageOperation('Rolling back', 'rollback', 'Rolled back'), snapMsg);
+ });
+
+ it('tests rbd/trash/move messages', () => {
+ finishedTask.name = 'rbd/trash/move';
+ testMessages(
+ new TaskMessageOperation('Moving', 'move', 'Moved'),
+ `image '${metadata.image_spec}' to trash`
+ );
+ testErrorCode(2, `Could not find image.`);
+ });
+
+ it('tests rbd/trash/restore messages', () => {
+ finishedTask.name = 'rbd/trash/restore';
+ testMessages(
+ new TaskMessageOperation('Restoring', 'restore', 'Restored'),
+ `image '${metadata.image_id_spec}' into '${metadata.new_image_name}'`
+ );
+ testErrorCode(17, `Image name '${metadata.new_image_name}' is already in use.`);
+ });
+
+ it('tests rbd/trash/remove messages', () => {
+ finishedTask.name = 'rbd/trash/remove';
+ testDelete(`image '${metadata.image_id_spec}'`);
+ });
+
+ it('tests rbd/trash/purge messages', () => {
+ finishedTask.name = 'rbd/trash/purge';
+ testMessages(
+ new TaskMessageOperation('Purging', 'purge', 'Purged'),
+ `images from '${metadata.pool_name}'`
+ );
+ });
+ });
+ describe('rbd tasks', () => {
+ let metadata;
+ let modeMsg: string;
+ let peerMsg: string;
+
+ beforeEach(() => {
+ metadata = {
+ pool_name: 'somePool'
+ };
+ modeMsg = `mirror mode for pool '${metadata.pool_name}'`;
+ peerMsg = `mirror peer for pool '${metadata.pool_name}'`;
+ finishedTask.metadata = metadata;
+ });
+ it('tests rbd/mirroring/site_name/edit messages', () => {
+ finishedTask.name = 'rbd/mirroring/site_name/edit';
+ testUpdate('mirroring site name');
+ });
+ it('tests rbd/mirroring/bootstrap/create messages', () => {
+ finishedTask.name = 'rbd/mirroring/bootstrap/create';
+ testCreate('bootstrap token');
+ });
+ it('tests rbd/mirroring/bootstrap/import messages', () => {
+ finishedTask.name = 'rbd/mirroring/bootstrap/import';
+ testImport('bootstrap token');
+ });
+ it('tests rbd/mirroring/pool/edit messages', () => {
+ finishedTask.name = 'rbd/mirroring/pool/edit';
+ testUpdate(modeMsg);
+ testErrorCode(16, 'Cannot disable mirroring because it contains a peer.');
+ });
+ it('tests rbd/mirroring/peer/edit messages', () => {
+ finishedTask.name = 'rbd/mirroring/peer/edit';
+ testUpdate(peerMsg);
+ });
+ it('tests rbd/mirroring/peer/add messages', () => {
+ finishedTask.name = 'rbd/mirroring/peer/add';
+ testCreate(peerMsg);
+ });
+ it('tests rbd/mirroring/peer/delete messages', () => {
+ finishedTask.name = 'rbd/mirroring/peer/delete';
+ testDelete(peerMsg);
+ });
+ });
+ });
+});
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
new file mode 100644
index 000000000..5adabe211
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
@@ -0,0 +1,424 @@
+import { Injectable } from '@angular/core';
+
+import { Components } from '../enum/components.enum';
+import { FinishedTask } from '../models/finished-task';
+import { ImageSpec } from '../models/image-spec';
+import { Task } from '../models/task';
+
+export class TaskMessageOperation {
+ running: string;
+ failure: string;
+ success: string;
+
+ constructor(running: string, failure: string, success: string) {
+ this.running = running;
+ this.failure = failure;
+ this.success = success;
+ }
+}
+
+class TaskMessage {
+ operation: TaskMessageOperation;
+ involves: (object: any) => string;
+ errors: (metadata: any) => object;
+
+ failure(metadata: any): string {
+ return $localize`Failed to ${this.operation.failure} ${this.involves(metadata)}`;
+ }
+
+ running(metadata: any): string {
+ return `${this.operation.running} ${this.involves(metadata)}`;
+ }
+
+ success(metadata: any): string {
+ return `${this.operation.success} ${this.involves(metadata)}`;
+ }
+
+ constructor(
+ operation: TaskMessageOperation,
+ involves: (metadata: any) => string,
+ errors?: (metadata: any) => object
+ ) {
+ this.operation = operation;
+ this.involves = involves;
+ this.errors = errors || (() => ({}));
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TaskMessageService {
+ defaultMessage = this.newTaskMessage(
+ new TaskMessageOperation($localize`Executing`, $localize`execute`, $localize`Executed`),
+ (metadata) => {
+ return (
+ (metadata && (Components[metadata.component] || metadata.component)) ||
+ $localize`unknown task`
+ );
+ },
+ () => {
+ return {};
+ }
+ );
+
+ commonOperations = {
+ create: new TaskMessageOperation($localize`Creating`, $localize`create`, $localize`Created`),
+ update: new TaskMessageOperation($localize`Updating`, $localize`update`, $localize`Updated`),
+ delete: new TaskMessageOperation($localize`Deleting`, $localize`delete`, $localize`Deleted`),
+ add: new TaskMessageOperation($localize`Adding`, $localize`add`, $localize`Added`),
+ remove: new TaskMessageOperation($localize`Removing`, $localize`remove`, $localize`Removed`),
+ import: new TaskMessageOperation($localize`Importing`, $localize`import`, $localize`Imported`)
+ };
+
+ rbd = {
+ default: (metadata: any) => $localize`RBD '${metadata.image_spec}'`,
+ create: (metadata: any) => {
+ const id = new ImageSpec(
+ metadata.pool_name,
+ metadata.namespace,
+ metadata.image_name
+ ).toString();
+ return $localize`RBD '${id}'`;
+ },
+ child: (metadata: any) => {
+ const id = new ImageSpec(
+ metadata.child_pool_name,
+ metadata.child_namespace,
+ metadata.child_image_name
+ ).toString();
+ return $localize`RBD '${id}'`;
+ },
+ destination: (metadata: any) => {
+ const id = new ImageSpec(
+ metadata.dest_pool_name,
+ metadata.dest_namespace,
+ metadata.dest_image_name
+ ).toString();
+ return $localize`RBD '${id}'`;
+ },
+ snapshot: (metadata: any) =>
+ $localize`RBD snapshot '${metadata.image_spec}@${metadata.snapshot_name}'`
+ };
+
+ rbd_mirroring = {
+ site_name: () => $localize`mirroring site name`,
+ bootstrap: () => $localize`bootstrap token`,
+ pool: (metadata: any) => $localize`mirror mode for pool '${metadata.pool_name}'`,
+ pool_peer: (metadata: any) => $localize`mirror peer for pool '${metadata.pool_name}'`
+ };
+
+ grafana = {
+ update_dashboards: () => $localize`all dashboards`
+ };
+
+ messages = {
+ // Host tasks
+ 'host/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.host(metadata)),
+ 'host/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+ this.host(metadata)
+ ),
+ 'host/identify_device': this.newTaskMessage(
+ new TaskMessageOperation($localize`Identifying`, $localize`identify`, $localize`Identified`),
+ (metadata) => $localize`device '${metadata.device}' on host '${metadata.hostname}'`
+ ),
+ // OSD tasks
+ 'osd/create': this.newTaskMessage(
+ this.commonOperations.create,
+ (metadata) => $localize`OSDs (DriveGroups: ${metadata.tracking_id})`
+ ),
+ 'osd/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.osd(metadata)
+ ),
+ // Pool tasks
+ 'pool/create': this.newTaskMessage(
+ this.commonOperations.create,
+ (metadata) => this.pool(metadata),
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.pool(metadata)}.`
+ })
+ ),
+ 'pool/edit': this.newTaskMessage(
+ this.commonOperations.update,
+ (metadata) => this.pool(metadata),
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.pool(metadata)}.`
+ })
+ ),
+ 'pool/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.pool(metadata)
+ ),
+ // Erasure code profile tasks
+ 'ecp/create': this.newTaskMessage(
+ this.commonOperations.create,
+ (metadata) => this.ecp(metadata),
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.ecp(metadata)}.`
+ })
+ ),
+ 'ecp/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.ecp(metadata)
+ ),
+ // Crush rule tasks
+ 'crushRule/create': this.newTaskMessage(
+ this.commonOperations.create,
+ (metadata) => this.crushRule(metadata),
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.crushRule(metadata)}.`
+ })
+ ),
+ 'crushRule/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.crushRule(metadata)
+ ),
+ // RBD tasks
+ 'rbd/create': this.newTaskMessage(
+ this.commonOperations.create,
+ this.rbd.create,
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.rbd.create(metadata)}.`
+ })
+ ),
+ 'rbd/edit': this.newTaskMessage(this.commonOperations.update, this.rbd.default, (metadata) => ({
+ '17': $localize`Name is already used by ${this.rbd.default(metadata)}.`
+ })),
+ 'rbd/delete': this.newTaskMessage(
+ this.commonOperations.delete,
+ this.rbd.default,
+ (metadata) => ({
+ '16': $localize`${this.rbd.default(metadata)} is busy.`,
+ '39': $localize`${this.rbd.default(metadata)} contains snapshots.`
+ })
+ ),
+ 'rbd/clone': this.newTaskMessage(
+ new TaskMessageOperation($localize`Cloning`, $localize`clone`, $localize`Cloned`),
+ this.rbd.child,
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.rbd.child(metadata)}.`,
+ '22': $localize`Snapshot of ${this.rbd.child(metadata)} must be protected.`
+ })
+ ),
+ 'rbd/copy': this.newTaskMessage(
+ new TaskMessageOperation($localize`Copying`, $localize`copy`, $localize`Copied`),
+ this.rbd.destination,
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.rbd.destination(metadata)}.`
+ })
+ ),
+ 'rbd/flatten': this.newTaskMessage(
+ new TaskMessageOperation($localize`Flattening`, $localize`flatten`, $localize`Flattened`),
+ this.rbd.default
+ ),
+ // RBD snapshot tasks
+ 'rbd/snap/create': this.newTaskMessage(
+ this.commonOperations.create,
+ this.rbd.snapshot,
+ (metadata) => ({
+ '17': $localize`Name is already used by ${this.rbd.snapshot(metadata)}.`
+ })
+ ),
+ 'rbd/snap/edit': this.newTaskMessage(
+ this.commonOperations.update,
+ this.rbd.snapshot,
+ (metadata) => ({
+ '16': $localize`Cannot unprotect ${this.rbd.snapshot(
+ metadata
+ )} because it contains child images.`
+ })
+ ),
+ 'rbd/snap/delete': this.newTaskMessage(
+ this.commonOperations.delete,
+ this.rbd.snapshot,
+ (metadata) => ({
+ '16': $localize`Cannot delete ${this.rbd.snapshot(metadata)} because it's protected.`
+ })
+ ),
+ 'rbd/snap/rollback': this.newTaskMessage(
+ new TaskMessageOperation(
+ $localize`Rolling back`,
+ $localize`rollback`,
+ $localize`Rolled back`
+ ),
+ this.rbd.snapshot
+ ),
+ // RBD trash tasks
+ 'rbd/trash/move': this.newTaskMessage(
+ new TaskMessageOperation($localize`Moving`, $localize`move`, $localize`Moved`),
+ (metadata) => $localize`image '${metadata.image_spec}' to trash`,
+ () => ({
+ 2: $localize`Could not find image.`
+ })
+ ),
+ 'rbd/trash/restore': this.newTaskMessage(
+ new TaskMessageOperation($localize`Restoring`, $localize`restore`, $localize`Restored`),
+ (metadata) => $localize`image '${metadata.image_id_spec}' into '${metadata.new_image_name}'`,
+ (metadata) => ({
+ 17: $localize`Image name '${metadata.new_image_name}' is already in use.`
+ })
+ ),
+ 'rbd/trash/remove': this.newTaskMessage(
+ new TaskMessageOperation($localize`Deleting`, $localize`delete`, $localize`Deleted`),
+ (metadata) => $localize`image '${metadata.image_id_spec}'`
+ ),
+ 'rbd/trash/purge': this.newTaskMessage(
+ new TaskMessageOperation($localize`Purging`, $localize`purge`, $localize`Purged`),
+ (metadata) => {
+ let message = $localize`all pools`;
+ if (metadata.pool_name) {
+ message = `'${metadata.pool_name}'`;
+ }
+ return $localize`images from ${message}`;
+ }
+ ),
+ // RBD mirroring tasks
+ 'rbd/mirroring/site_name/edit': this.newTaskMessage(
+ this.commonOperations.update,
+ this.rbd_mirroring.site_name,
+ () => ({})
+ ),
+ 'rbd/mirroring/bootstrap/create': this.newTaskMessage(
+ this.commonOperations.create,
+ this.rbd_mirroring.bootstrap,
+ () => ({})
+ ),
+ 'rbd/mirroring/bootstrap/import': this.newTaskMessage(
+ this.commonOperations.import,
+ this.rbd_mirroring.bootstrap,
+ () => ({})
+ ),
+ 'rbd/mirroring/pool/edit': this.newTaskMessage(
+ this.commonOperations.update,
+ this.rbd_mirroring.pool,
+ () => ({
+ 16: $localize`Cannot disable mirroring because it contains a peer.`
+ })
+ ),
+ 'rbd/mirroring/peer/add': this.newTaskMessage(
+ this.commonOperations.create,
+ this.rbd_mirroring.pool_peer,
+ () => ({})
+ ),
+ 'rbd/mirroring/peer/edit': this.newTaskMessage(
+ this.commonOperations.update,
+ this.rbd_mirroring.pool_peer,
+ () => ({})
+ ),
+ 'rbd/mirroring/peer/delete': this.newTaskMessage(
+ this.commonOperations.delete,
+ this.rbd_mirroring.pool_peer,
+ () => ({})
+ ),
+ // iSCSI target tasks
+ 'iscsi/target/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.iscsiTarget(metadata)
+ ),
+ 'iscsi/target/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+ this.iscsiTarget(metadata)
+ ),
+ 'iscsi/target/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.iscsiTarget(metadata)
+ ),
+ 'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.nfs(metadata)
+ ),
+ 'nfs/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => this.nfs(metadata)),
+ 'nfs/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.nfs(metadata)
+ ),
+ // Grafana tasks
+ 'grafana/dashboards/update': this.newTaskMessage(
+ this.commonOperations.update,
+ this.grafana.update_dashboards,
+ () => ({})
+ ),
+ // Service tasks
+ 'service/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.service(metadata)
+ ),
+ 'service/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+ this.service(metadata)
+ ),
+ 'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.service(metadata)
+ )
+ };
+
+ newTaskMessage(
+ operation: TaskMessageOperation,
+ involves: (metadata: any) => string,
+ errors?: (metadata: any) => object
+ ) {
+ return new TaskMessage(operation, involves, errors);
+ }
+
+ host(metadata: any) {
+ return $localize`host '${metadata.hostname}'`;
+ }
+
+ osd(metadata: any) {
+ return $localize`OSD '${metadata.svc_id}'`;
+ }
+
+ pool(metadata: any) {
+ return $localize`pool '${metadata.pool_name}'`;
+ }
+
+ ecp(metadata: any) {
+ return $localize`erasure code profile '${metadata.name}'`;
+ }
+
+ crushRule(metadata: any) {
+ return $localize`crush rule '${metadata.name}'`;
+ }
+
+ iscsiTarget(metadata: any) {
+ return $localize`target '${metadata.target_iqn}'`;
+ }
+
+ nfs(metadata: any) {
+ return $localize`NFS '${metadata.cluster_id}\:${
+ metadata.export_id ? metadata.export_id : metadata.path
+ }'`;
+ }
+
+ service(metadata: any) {
+ return $localize`Service '${metadata.service_name}'`;
+ }
+
+ _getTaskTitle(task: Task) {
+ if (task.name && task.name.startsWith('progress/')) {
+ // we don't fill the failure string because, at least for now, all
+ // progress module tasks will be considered successful
+ return this.newTaskMessage(
+ new TaskMessageOperation(
+ task.name.replace('progress/', ''),
+ '',
+ task.name.replace('progress/', '')
+ ),
+ (_metadata) => ''
+ );
+ }
+ return this.messages[task.name] || this.defaultMessage;
+ }
+
+ getSuccessTitle(task: FinishedTask) {
+ return this._getTaskTitle(task).success(task.metadata);
+ }
+
+ getErrorMessage(task: FinishedTask) {
+ return (
+ this._getTaskTitle(task).errors(task.metadata)[task.exception.code] || task.exception.detail
+ );
+ }
+
+ getErrorTitle(task: Task) {
+ return this._getTaskTitle(task).failure(task.metadata);
+ }
+
+ getRunningTitle(task: Task) {
+ return this._getTaskTitle(task).running(task.metadata);
+ }
+
+ getRunningText(task: Task) {
+ return this._getTaskTitle(task).operation.running;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts
new file mode 100644
index 000000000..e81962211
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts
@@ -0,0 +1,98 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { Observable } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FinishedTask } from '../models/finished-task';
+import { SharedModule } from '../shared.module';
+import { NotificationService } from './notification.service';
+import { SummaryService } from './summary.service';
+import { TaskManagerService } from './task-manager.service';
+import { TaskWrapperService } from './task-wrapper.service';
+
+describe('TaskWrapperService', () => {
+ let service: TaskWrapperService;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, ToastrModule.forRoot(), SharedModule, RouterTestingModule],
+ providers: [TaskWrapperService]
+ });
+
+ beforeEach(inject([TaskWrapperService], (wrapper: TaskWrapperService) => {
+ service = wrapper;
+ }));
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('wrapTaskAroundCall', () => {
+ let notify: NotificationService;
+ let passed: boolean;
+ let summaryService: SummaryService;
+
+ const fakeCall = (status?: number) =>
+ new Observable((observer) => {
+ if (!status) {
+ observer.error({ error: 'failed' });
+ }
+ observer.next({ status: status });
+ observer.complete();
+ });
+
+ const callWrapTaskAroundCall = (status: number, name: string) => {
+ return service.wrapTaskAroundCall({
+ task: new FinishedTask(name, { sth: 'else' }),
+ call: fakeCall(status)
+ });
+ };
+
+ beforeEach(() => {
+ passed = false;
+ notify = TestBed.inject(NotificationService);
+ summaryService = TestBed.inject(SummaryService);
+ spyOn(notify, 'show');
+ spyOn(notify, 'notifyTask').and.stub();
+ spyOn(service, '_handleExecutingTasks').and.callThrough();
+ spyOn(summaryService, 'addRunningTask').and.callThrough();
+ });
+
+ it('should simulate a synchronous task', () => {
+ callWrapTaskAroundCall(200, 'sync').subscribe({ complete: () => (passed = true) });
+ expect(service._handleExecutingTasks).not.toHaveBeenCalled();
+ expect(passed).toBeTruthy();
+ expect(summaryService.addRunningTask).not.toHaveBeenCalled();
+ });
+
+ it('should simulate a asynchronous task', () => {
+ callWrapTaskAroundCall(202, 'async').subscribe({ complete: () => (passed = true) });
+ expect(service._handleExecutingTasks).toHaveBeenCalled();
+ expect(passed).toBeTruthy();
+ expect(summaryService.addRunningTask).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call notifyTask if asynchronous task would have been finished', () => {
+ const taskManager = TestBed.inject(TaskManagerService);
+ spyOn(taskManager, 'subscribe').and.callFake((_name, _metadata, onTaskFinished) => {
+ onTaskFinished();
+ });
+ callWrapTaskAroundCall(202, 'async').subscribe({ complete: () => (passed = true) });
+ expect(notify.notifyTask).toHaveBeenCalled();
+ });
+
+ it('should simulate a task failure', () => {
+ callWrapTaskAroundCall(null, 'async').subscribe({ error: () => (passed = true) });
+ expect(service._handleExecutingTasks).not.toHaveBeenCalled();
+ expect(passed).toBeTruthy();
+ expect(summaryService.addRunningTask).not.toHaveBeenCalled();
+ /**
+ * A notification will be raised by the API interceptor.
+ * This resolves this bug https://tracker.ceph.com/issues/25139
+ */
+ expect(notify.notifyTask).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts
new file mode 100644
index 000000000..721e1edcd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts
@@ -0,0 +1,68 @@
+import { Injectable } from '@angular/core';
+
+import { Observable, Subscriber } from 'rxjs';
+
+import { NotificationType } from '../enum/notification-type.enum';
+import { CdNotificationConfig } from '../models/cd-notification';
+import { ExecutingTask } from '../models/executing-task';
+import { FinishedTask } from '../models/finished-task';
+import { NotificationService } from './notification.service';
+import { SummaryService } from './summary.service';
+import { TaskManagerService } from './task-manager.service';
+import { TaskMessageService } from './task-message.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TaskWrapperService {
+ constructor(
+ private notificationService: NotificationService,
+ private summaryService: SummaryService,
+ private taskMessageService: TaskMessageService,
+ private taskManagerService: TaskManagerService
+ ) {}
+
+ wrapTaskAroundCall({ task, call }: { task: FinishedTask; call: Observable<any> }) {
+ return new Observable((observer: Subscriber<any>) => {
+ call.subscribe(
+ (resp) => {
+ if (resp.status === 202) {
+ this._handleExecutingTasks(task);
+ } else {
+ this.summaryService.refresh();
+ task.success = true;
+ this.notificationService.notifyTask(task);
+ }
+ },
+ (resp) => {
+ task.success = false;
+ task.exception = resp.error;
+ observer.error(resp);
+ },
+ () => {
+ observer.complete();
+ }
+ );
+ });
+ }
+
+ _handleExecutingTasks(task: FinishedTask) {
+ const notification = new CdNotificationConfig(
+ NotificationType.info,
+ this.taskMessageService.getRunningTitle(task)
+ );
+ notification.isFinishedTask = true;
+ this.notificationService.show(notification);
+
+ const executingTask = new ExecutingTask(task.name, task.metadata);
+ this.summaryService.addRunningTask(executingTask);
+
+ this.taskManagerService.subscribe(
+ executingTask.name,
+ executingTask.metadata,
+ (asyncTask: FinishedTask) => {
+ this.notificationService.notifyTask(asyncTask);
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts
new file mode 100644
index 000000000..ea1f910e1
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts
@@ -0,0 +1,33 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TelemetryNotificationService } from './telemetry-notification.service';
+
+describe('TelemetryNotificationService', () => {
+ let service: TelemetryNotificationService;
+
+ configureTestBed({
+ providers: [TelemetryNotificationService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TelemetryNotificationService);
+ spyOn(service.update, 'emit');
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should set notification visibility to true', () => {
+ service.setVisibility(true);
+ expect(service.visible).toBe(true);
+ expect(service.update.emit).toHaveBeenCalledWith(true);
+ });
+
+ it('should set notification visibility to false', () => {
+ service.setVisibility(false);
+ expect(service.visible).toBe(false);
+ expect(service.update.emit).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts
new file mode 100644
index 000000000..fcb2e0264
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts
@@ -0,0 +1,16 @@
+import { EventEmitter, Injectable, Output } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TelemetryNotificationService {
+ visible = false;
+
+ @Output()
+ update: EventEmitter<boolean> = new EventEmitter<boolean>();
+
+ setVisibility(visible: boolean) {
+ this.visible = visible;
+ this.update.emit(visible);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts
new file mode 100644
index 000000000..f9ff4d29d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts
@@ -0,0 +1,20 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TextToDownloadService } from './text-to-download.service';
+
+describe('TextToDownloadService', () => {
+ let service: TextToDownloadService;
+
+ configureTestBed({
+ providers: [TextToDownloadService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TextToDownloadService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts
new file mode 100644
index 000000000..6e63287ea
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts
@@ -0,0 +1,12 @@
+import { Injectable } from '@angular/core';
+
+import { saveAs } from 'file-saver';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TextToDownloadService {
+ download(downloadText: string, filename?: string) {
+ saveAs(new Blob([downloadText]), filename);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts
new file mode 100644
index 000000000..52be82b09
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts
@@ -0,0 +1,71 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TimeDiffService } from './time-diff.service';
+
+describe('TimeDiffService', () => {
+ let service: TimeDiffService;
+ const baseTime = new Date('2022-02-22T00:00:00');
+
+ configureTestBed({
+ providers: [TimeDiffService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TimeDiffService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('calculates a new date that happens after the given date', () => {
+ expect(service.calculateDate(new Date('2022-02-28T04:05:00'), '2h')).toEqual(
+ new Date('2022-02-28T06:05:00')
+ );
+ expect(service.calculateDate(baseTime, '15m')).toEqual(new Date('2022-02-22T00:15'));
+ expect(service.calculateDate(baseTime, '5d 23h')).toEqual(new Date('2022-02-27T23:00'));
+ });
+
+ it('calculates a new date that happens before the given date', () => {
+ expect(service.calculateDate(new Date('2022-02-22T02:00:00'), '2h', true)).toEqual(baseTime);
+ });
+
+ it('calculates the difference of two dates', () => {
+ expect(
+ service.calculateDuration(new Date('2022-02-22T00:45:00'), new Date('2022-02-22T02:00:00'))
+ ).toBe('1h 15m');
+ expect(service.calculateDuration(baseTime, new Date('2022-02-28T04:05:00'))).toBe('6d 4h 5m');
+ });
+
+ it('should return an empty string if time diff is less then a minute', () => {
+ const ts = 1568361327000;
+ expect(service.calculateDuration(new Date(ts), new Date(ts + 120))).toBe('');
+ });
+
+ describe('testing duration calculation in detail', () => {
+ const minutes = 60 * 1000;
+ const hours = 60 * minutes;
+ const days = 24 * hours;
+
+ it('should allow different writings', () => {
+ const expectDurationToBeMs = (duration: string, ms: number) =>
+ expect(service['getDurationMs'](duration)).toBe(ms);
+ expectDurationToBeMs('2h', 2 * hours);
+ expectDurationToBeMs('4 Days', 4 * days);
+ expectDurationToBeMs('3 minutes', 3 * minutes);
+ expectDurationToBeMs('4 Days 2h 3 minutes', 4 * days + 2 * hours + 3 * minutes);
+ expectDurationToBeMs('5d3h120m', 5 * days + 5 * hours);
+ });
+
+ it('should create duration string from ms', () => {
+ const expectMsToBeDuration = (ms: number, duration: string) =>
+ expect(service['getDuration'](ms)).toBe(duration);
+ expectMsToBeDuration(2 * hours, '2h');
+ expectMsToBeDuration(4 * days, '4d');
+ expectMsToBeDuration(3 * minutes, '3m');
+ expectMsToBeDuration(4 * days + 2 * hours + 3 * minutes, '4d 2h 3m');
+ expectMsToBeDuration(service['getDurationMs']('5d3h120m'), '5d 5h');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts
new file mode 100644
index 000000000..37477658c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts
@@ -0,0 +1,55 @@
+import { Injectable } from '@angular/core';
+
+import _ from 'lodash';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TimeDiffService {
+ calculateDuration(startDate: Date, endDate: Date): string {
+ const startTime = +startDate;
+ const endTime = +endDate;
+ const duration = this.getDuration(Math.abs(startTime - endTime));
+ if (startTime > endTime) {
+ return '-' + duration;
+ }
+ return duration;
+ }
+
+ /**
+ * Get the duration in the format '[Nd] [Nh] [Nm]', e.g. '2d 1h 15m'.
+ * @param ms The time in milliseconds.
+ * @return The duration. An empty string is returned if the duration is
+ * less than a minute.
+ */
+ private getDuration(ms: number): string {
+ const date = new Date(ms);
+ const h = date.getUTCHours();
+ const m = date.getUTCMinutes();
+ const d = Math.floor(ms / (24 * 3600 * 1000));
+
+ const format = (n: number, s: string) => (n ? n + s : n);
+ return [format(d, 'd'), format(h, 'h'), format(m, 'm')].filter((x) => x).join(' ');
+ }
+
+ calculateDate(date: Date, duration: string, reverse?: boolean): Date {
+ const time = +date;
+ if (_.isNaN(time)) {
+ return undefined;
+ }
+ const diff = this.getDurationMs(duration) * (reverse ? -1 : 1);
+ return new Date(time + diff);
+ }
+
+ private getDurationMs(duration: string): number {
+ const d = this.getNumbersFromString(duration, 'd');
+ const h = this.getNumbersFromString(duration, 'h');
+ const m = this.getNumbersFromString(duration, 'm');
+ return ((d * 24 + h) * 60 + m) * 60000;
+ }
+
+ private getNumbersFromString(duration: string, prefix: string): number {
+ const match = duration.match(new RegExp(`[0-9 ]+${prefix}`, 'i'));
+ return match ? parseInt(match[0], 10) : 0;
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts
new file mode 100644
index 000000000..10b528e3a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts
@@ -0,0 +1,68 @@
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { of, Subscription } from 'rxjs';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TimerService } from './timer.service';
+
+describe('TimerService', () => {
+ let service: TimerService;
+ let subs: Subscription;
+ let receivedData: any[];
+ const next = () => of(true);
+ const observer = (data: boolean) => {
+ receivedData.push(data);
+ };
+
+ configureTestBed({
+ providers: [TimerService]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(TimerService);
+ receivedData = [];
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should not emit any value when no subscribers', fakeAsync(() => {
+ subs = service.get(next).subscribe(observer);
+ tick(service.DEFAULT_REFRESH_INTERVAL);
+ expect(receivedData.length).toEqual(2);
+
+ subs.unsubscribe();
+
+ tick(service.DEFAULT_REFRESH_INTERVAL);
+ expect(receivedData.length).toEqual(2);
+ }));
+
+ it('should emit value with no dueTime and no refresh interval', fakeAsync(() => {
+ subs = service.get(next, null, null).subscribe(observer);
+ tick(service.DEFAULT_REFRESH_INTERVAL);
+ expect(receivedData.length).toEqual(1);
+ expect(receivedData).toEqual([true]);
+
+ subs.unsubscribe();
+ }));
+
+ it('should emit expected values when refresh interval + no dueTime', fakeAsync(() => {
+ subs = service.get(next).subscribe(observer);
+ tick(service.DEFAULT_REFRESH_INTERVAL * 2);
+ expect(receivedData.length).toEqual(3);
+ expect(receivedData).toEqual([true, true, true]);
+
+ subs.unsubscribe();
+ }));
+
+ it('should emit expected values when dueTime equal to refresh interval', fakeAsync(() => {
+ const dueTime = 1000;
+ subs = service.get(next, service.DEFAULT_REFRESH_INTERVAL, dueTime).subscribe(observer);
+ tick(service.DEFAULT_REFRESH_INTERVAL * 2);
+ expect(receivedData.length).toEqual(2);
+ expect(receivedData).toEqual([true, true]);
+
+ subs.unsubscribe();
+ }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts
new file mode 100644
index 000000000..716b71096
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core';
+
+import { Observable, timer } from 'rxjs';
+import { observeOn, shareReplay, switchMap } from 'rxjs/operators';
+
+import { whenPageVisible } from '../rxjs/operators/page-visibilty.operator';
+import { NgZoneSchedulerService } from './ngzone-scheduler.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TimerService {
+ readonly DEFAULT_REFRESH_INTERVAL = 5000;
+ readonly DEFAULT_DUE_TIME = 0;
+ constructor(private ngZone: NgZoneSchedulerService) {}
+
+ get(
+ next: () => Observable<any>,
+ refreshInterval: number = this.DEFAULT_REFRESH_INTERVAL,
+ dueTime: number = this.DEFAULT_DUE_TIME
+ ): Observable<any> {
+ return timer(dueTime, refreshInterval, this.ngZone.leave).pipe(
+ observeOn(this.ngZone.enter),
+ switchMap(next),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ whenPageVisible()
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts
new file mode 100644
index 000000000..bc8b54ca3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts
@@ -0,0 +1,37 @@
+import { URLVerbs } from '../constants/app.constants';
+import { URLBuilderService } from './url-builder.service';
+
+describe('URLBuilderService', () => {
+ const BASE = 'pool';
+ const urlBuilder = new URLBuilderService(BASE);
+
+ it('get base', () => {
+ expect(urlBuilder.base).toBe(BASE);
+ });
+
+ it('build absolute URL', () => {
+ expect(URLBuilderService.buildURL(true, urlBuilder.base, URLVerbs.CREATE)).toBe(
+ `/${urlBuilder.base}/${URLVerbs.CREATE}`
+ );
+ });
+
+ it('build relative URL', () => {
+ expect(URLBuilderService.buildURL(false, urlBuilder.base, URLVerbs.CREATE)).toBe(
+ `${urlBuilder.base}/${URLVerbs.CREATE}`
+ );
+ });
+
+ it('get Create URL', () => {
+ expect(urlBuilder.getCreate()).toBe(`/${urlBuilder.base}/${URLVerbs.CREATE}`);
+ });
+
+ it('get Create From URL', () => {
+ const id = 'someId';
+ expect(urlBuilder.getCreateFrom(id)).toBe(`/${urlBuilder.base}/${URLVerbs.CREATE}/${id}`);
+ });
+
+ it('get Edit URL with item', () => {
+ const item = 'test_pool';
+ expect(urlBuilder.getEdit(item)).toBe(`/${urlBuilder.base}/${URLVerbs.EDIT}/${item}`);
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts
new file mode 100644
index 000000000..b06f307ad
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts
@@ -0,0 +1,50 @@
+import { Location } from '@angular/common';
+
+import { URLVerbs } from '../constants/app.constants';
+
+export class URLBuilderService {
+ constructor(readonly base: string) {}
+
+ private static concatURLSegments(segments: string[]): string {
+ return segments.reduce(Location.joinWithSlash);
+ }
+
+ static buildURL(absolute: boolean, ...segments: string[]): string {
+ return URLBuilderService.concatURLSegments([...(absolute ? ['/'] : []), ...segments]);
+ }
+
+ private getURL(verb: URLVerbs, absolute = true, ...segments: string[]): string {
+ return URLBuilderService.buildURL(absolute, this.base, verb, ...segments);
+ }
+
+ getCreate(absolute = true): string {
+ return this.getURL(URLVerbs.CREATE, absolute);
+ }
+
+ getCreateFrom(item: string, absolute = true): string {
+ return this.getURL(URLVerbs.CREATE, absolute, item);
+ }
+
+ getDelete(absolute = true): string {
+ return this.getURL(URLVerbs.DELETE, absolute);
+ }
+
+ getEdit(item: string, absolute = true): string {
+ return this.getURL(URLVerbs.EDIT, absolute, item);
+ }
+ getUpdate(item: string, absolute = true): string {
+ return this.getURL(URLVerbs.UPDATE, absolute, item);
+ }
+
+ getAdd(absolute = true): string {
+ return this.getURL(URLVerbs.ADD, absolute);
+ }
+ getRemove(absolute = true): string {
+ return this.getURL(URLVerbs.REMOVE, absolute);
+ }
+
+ // Prometheus wording
+ getRecreate(item: string, absolute = true): string {
+ return this.getURL(URLVerbs.RECREATE, absolute, item);
+ }
+}
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 000000000..47c214975
--- /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 000000000..e0fb2be94
--- /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<WizardStepModel[]>;
+ currentStep$: BehaviorSubject<WizardStepModel> = new BehaviorSubject<WizardStepModel>(null);
+
+ constructor() {
+ this.steps$ = new BehaviorSubject<WizardStepModel[]>(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<WizardStepModel[]>(steps);
+ }
+
+ setCurrentStep(step: WizardStepModel): void {
+ this.currentStep$.next(step);
+ }
+
+ getCurrentStep(): Observable<WizardStepModel> {
+ return this.currentStep$.asObservable();
+ }
+
+ getSteps(): Observable<WizardStepModel[]> {
+ 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/app/shared/shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts
new file mode 100644
index 000000000..905721fa4
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts
@@ -0,0 +1,19 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { ComponentsModule } from './components/components.module';
+import { DataTableModule } from './datatable/datatable.module';
+import { DirectivesModule } from './directives/directives.module';
+import { PipesModule } from './pipes/pipes.module';
+import { AuthGuardService } from './services/auth-guard.service';
+import { AuthStorageService } from './services/auth-storage.service';
+import { FormatterService } from './services/formatter.service';
+
+@NgModule({
+ imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule],
+ declarations: [],
+ exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
+ providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]
+})
+export class SharedModule {}