summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/frontend/src/app/shared
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:45:59 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:45:59 +0000
commit19fcec84d8d7d21e796c7624e521b60d28ee21ed (patch)
tree42d26aa27d1e3f7c0b8bd3fd14e7d7082f5008dc /src/pybind/mgr/dashboard/frontend/src/app/shared
parentInitial commit. (diff)
downloadceph-19fcec84d8d7d21e796c7624e521b60d28ee21ed.tar.xz
ceph-19fcec84d8d7d21e796c7624e521b60d28ee21ed.zip
Adding upstream version 16.2.11+ds.upstream/16.2.11+dsupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/shared')
-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
441 files changed, 28193 insertions, 0 deletions
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 {}