From b2216e53d1bd01cf73c87a683157d3f0831ce0c3 Mon Sep 17 00:00:00 2001 From: Michelle Franke Date: Sat, 4 Nov 2023 10:27:48 +0100 Subject: [PATCH] [#23] Add example user interface implementation At this PR we have created an example user interface for a Google Cloud based deployment. The UI is able to communicate with the Device Registry API and the Device Communication API. - Added new module to support a user interface based on Angular 15 - Added components for tenants, devices, credentials, configs and states - Added services for communicating with the APIs - Added Dockerfile - Updated Readme - Added request/response command capability --------- Signed-off-by: michelle Signed-off-by: patrick Signed-off-by: Muhammed Signed-off-by: Matthias Kaemmer Signed-off-by: g.dimitropoulos Signed-off-by: Hoang Sa Nguyen Signed-off-by: julian Co-authored-by: patrick Co-authored-by: Muhammed Co-authored-by: g.dimitropoulos Co-authored-by: Hoang Sa Nguyen Co-authored-by: julian --- .gitignore | 1 + README.md | 9 + device-management-ui/.editorconfig | 16 + device-management-ui/.gitignore | 42 +++ device-management-ui/Dockerfile | 24 ++ device-management-ui/README.md | 65 ++++ device-management-ui/angular.json | 122 ++++++++ device-management-ui/karma.conf.js | 34 +++ device-management-ui/nginx.conf | 16 + device-management-ui/package.json | 47 +++ device-management-ui/proxy.config.json | 9 + .../src/app/app.component.html | 9 + .../src/app/app.component.scss | 0 .../src/app/app.component.spec.ts | 43 +++ device-management-ui/src/app/app.component.ts | 30 ++ device-management-ui/src/app/app.module.ts | 118 ++++++++ .../date-time/date-time-picker.component.html | 42 +++ .../date-time/date-time-picker.component.scss | 0 .../date-time-picker.component.spec.ts | 84 ++++++ .../date-time/date-time-picker.component.ts | 79 +++++ .../device-detail.component.html | 146 +++++++++ .../device-detail.component.scss | 12 + .../device-detail.component.spec.ts | 82 +++++ .../device-detail/device-detail.component.ts | 285 ++++++++++++++++++ .../list-authentication.component.html | 49 +++ .../list-authentication.component.scss | 3 + .../list-authentication.component.spec.ts | 70 +++++ .../list-authentication.component.ts | 170 +++++++++++ .../list-config/list-config.component.html | 37 +++ .../list-config/list-config.component.scss | 14 + .../list-config/list-config.component.spec.ts | 62 ++++ .../list-config/list-config.component.ts | 56 ++++ .../list-state/list-state.component.html | 22 ++ .../list-state/list-state.component.scss | 0 .../list-state/list-state.component.spec.ts | 41 +++ .../list-state/list-state.component.ts | 46 +++ .../device-list/device-list.component.html | 112 +++++++ .../device-list/device-list.component.scss | 35 +++ .../device-list/device-list.component.spec.ts | 81 +++++ .../device-list/device-list.component.ts | 264 ++++++++++++++++ .../gateway-list/gateway-list.component.html | 75 +++++ .../gateway-list/gateway-list.component.scss | 0 .../gateway-list.component.spec.ts | 80 +++++ .../gateway-list/gateway-list.component.ts | 177 +++++++++++ .../loader-spinner.component.scss | 0 .../loader-spinner.component.spec.ts | 38 +++ .../loader-spinner.component.ts | 41 +++ .../loading-spinner.component.html | 5 + .../create-and-bind-modal.component.html | 64 ++++ .../create-and-bind-modal.component.scss | 0 .../create-and-bind-modal.component.spec.ts | 233 ++++++++++++++ .../create-and-bind-modal.component.ts | 215 +++++++++++++ .../credentials-modal.component.html | 56 ++++ .../credentials-modal.component.scss | 0 .../credentials-modal.component.spec.ts | 192 ++++++++++++ .../credentials-modal.component.ts | 172 +++++++++++ .../device-password-modal.component.html | 62 ++++ .../device-password-modal.component.scss | 0 .../device-password-modal.component.spec.ts | 105 +++++++ .../device-password-modal.component.ts | 64 ++++ .../device-rpk-modal.component.html | 104 +++++++ .../device-rpk-modal.component.scss | 0 .../device-rpk-modal.component.spec.ts | 164 ++++++++++ .../device-rpk-modal.component.ts | 103 +++++++ .../modals/delete/delete.component.html | 21 ++ .../modals/delete/delete.component.scss | 0 .../modals/delete/delete.component.spec.ts | 59 ++++ .../modals/delete/delete.component.ts | 41 +++ .../modal-footer/modal-footer.component.html | 20 ++ .../modal-footer/modal-footer.component.scss | 0 .../modal-footer.component.spec.ts | 52 ++++ .../modal-footer/modal-footer.component.ts | 39 +++ .../modal-head/modal-head.component.html | 4 + .../modal-head/modal-head.component.scss | 0 .../modal-head/modal-head.component.spec.ts | 45 +++ .../modals/modal-head/modal-head.component.ts | 33 ++ .../select-devices.component.html | 39 +++ .../select-devices.component.scss | 50 +++ .../select-devices.component.spec.ts | 145 +++++++++ .../select-devices.component.ts | 77 +++++ .../send-command/send-command.component.html | 63 ++++ .../send-command/send-command.component.scss | 0 .../send-command.component.spec.ts | 118 ++++++++ .../send-command/send-command.component.ts | 97 ++++++ .../modals/tenant/tenant-modal.component.html | 42 +++ .../modals/tenant/tenant-modal.component.scss | 0 .../tenant/tenant-modal.component.spec.ts | 156 ++++++++++ .../modals/tenant/tenant-modal.component.ts | 98 ++++++ .../update-config-modal.component.html | 39 +++ .../update-config-modal.component.scss | 0 .../update-config-modal.component.spec.ts | 117 +++++++ .../update-config-modal.component.ts | 99 ++++++ .../pagination/pagination.component.html | 25 ++ .../pagination/pagination.component.scss | 0 .../pagination/pagination.component.spec.ts | 38 +++ .../pagination/pagination.component.ts | 42 +++ .../tenant-detail.component.html | 82 +++++ .../tenant-detail.component.scss | 7 + .../tenant-detail.component.spec.ts | 74 +++++ .../tenant-detail/tenant-detail.component.ts | 108 +++++++ .../tenant-list/tenant-list.component.html | 105 +++++++ .../tenant-list/tenant-list.component.scss | 8 + .../tenant-list/tenant-list.component.spec.ts | 107 +++++++ .../tenant-list/tenant-list.component.ts | 155 ++++++++++ .../toast-container.component.html | 15 + .../toast-container.component.scss | 0 .../toast-container.component.spec.ts | 48 +++ .../toast-container.component.ts | 32 ++ .../src/app/models/authentication-value.ts | 33 ++ .../src/app/models/command.ts | 21 ++ device-management-ui/src/app/models/config.ts | 25 ++ .../src/app/models/credentials/credentials.ts | 29 ++ .../src/app/models/credentials/secret.ts | 40 +++ device-management-ui/src/app/models/device.ts | 22 ++ .../src/app/models/environment.ts | 19 ++ device-management-ui/src/app/models/state.ts | 19 ++ device-management-ui/src/app/models/tenant.ts | 19 ++ .../src/app/prototypes/string-prototype.d.ts | 18 ++ .../src/app/prototypes/string-prototype1.ts | 21 ++ .../src/app/routing/app-routing.module.ts | 40 +++ .../src/app/services/api/api.service.spec.ts | 40 +++ .../src/app/services/api/api.service.ts | 42 +++ .../services/command/command.service.spec.ts | 61 ++++ .../app/services/command/command.service.ts | 40 +++ .../services/config/config.service.spec.ts | 74 +++++ .../src/app/services/config/config.service.ts | 49 +++ .../credentials/credentials.service.spec.ts | 76 +++++ .../credentials/credentials.service.ts | 49 +++ .../services/device/device.service.spec.ts | 164 ++++++++++ .../src/app/services/device/device.service.ts | 127 ++++++++ .../src/app/services/google/google.service.ts | 60 ++++ .../loading-spinner.service.spec.ts | 31 ++ .../loading-spinner.service.ts | 33 ++ .../notification/notification.service.spec.ts | 31 ++ .../notification/notification.service.ts | 45 +++ .../sortable-table.directive.spec.ts | 23 ++ .../sortable-table.directive.ts | 52 ++++ .../sortable-table.service.spec.ts | 99 ++++++ .../sortable-table/sortable-table.service.ts | 81 +++++ .../services/states/states.service.spec.ts | 61 ++++ .../src/app/services/states/states.service.ts | 39 +++ .../services/tenant/tenant.service.spec.ts | 105 +++++++ .../src/app/services/tenant/tenant.service.ts | 70 +++++ .../src/app/shared/loader.interceptor.spec.ts | 31 ++ .../src/app/shared/loader.interceptor.ts | 37 +++ .../src/app/shared/search-filter.pipe.spec.ts | 59 ++++ .../src/app/shared/search-filter.pipe.ts | 34 +++ .../src/app/shared/truncate.pipe.spec.ts | 45 +++ .../src/app/shared/truncate.pipe.ts | 30 ++ .../src/assets/_variables.scss | 17 ++ device-management-ui/src/assets/env.js | 4 + .../src/assets/env.template.js | 4 + .../src/assets/images/favicon.ico | Bin 0 -> 15086 bytes .../src/assets/images/logo_300px276px.png | Bin 0 -> 8691 bytes .../src/assets/images/sort-solid.svg | 1 + .../src/assets/images/sort-up-solid.svg | 1 + .../src/assets/scss/_breadcrumb.scss | 23 ++ .../src/assets/scss/_buttons.scss | 95 ++++++ .../src/assets/scss/_config-accordion.scss | 14 + .../src/assets/scss/_date-time-picker.scss | 73 +++++ .../src/assets/scss/_layout.scss | 106 +++++++ .../src/assets/scss/_lists.scss | 105 +++++++ .../src/assets/scss/_loader.scss | 16 + .../src/assets/scss/_login.scss | 32 ++ .../src/assets/scss/_logo.scss | 7 + .../src/assets/scss/_modals.scss | 50 +++ .../src/assets/scss/_pagination.scss | 25 ++ .../src/assets/scss/_responsive.scss | 55 ++++ .../src/assets/scss/_toasts.scss | 50 +++ .../environments/environment.development.ts | 21 ++ .../src/environments/environment.ts | 21 ++ device-management-ui/src/index.html | 16 + device-management-ui/src/main.ts | 22 ++ device-management-ui/src/styles.scss | 23 ++ device-management-ui/tsconfig.app.json | 15 + device-management-ui/tsconfig.json | 33 ++ device-management-ui/tsconfig.spec.json | 14 + 177 files changed, 9464 insertions(+) create mode 100644 device-management-ui/.editorconfig create mode 100644 device-management-ui/.gitignore create mode 100644 device-management-ui/Dockerfile create mode 100644 device-management-ui/README.md create mode 100644 device-management-ui/angular.json create mode 100644 device-management-ui/karma.conf.js create mode 100644 device-management-ui/nginx.conf create mode 100644 device-management-ui/package.json create mode 100644 device-management-ui/proxy.config.json create mode 100644 device-management-ui/src/app/app.component.html create mode 100644 device-management-ui/src/app/app.component.scss create mode 100644 device-management-ui/src/app/app.component.spec.ts create mode 100644 device-management-ui/src/app/app.component.ts create mode 100644 device-management-ui/src/app/app.module.ts create mode 100644 device-management-ui/src/app/components/date-time/date-time-picker.component.html create mode 100644 device-management-ui/src/app/components/date-time/date-time-picker.component.scss create mode 100644 device-management-ui/src/app/components/date-time/date-time-picker.component.spec.ts create mode 100644 device-management-ui/src/app/components/date-time/date-time-picker.component.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/device-detail.component.html create mode 100644 device-management-ui/src/app/components/devices/device-detail/device-detail.component.scss create mode 100644 device-management-ui/src/app/components/devices/device-detail/device-detail.component.spec.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/device-detail.component.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.html create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.scss create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.spec.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.html create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.scss create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.spec.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.html create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.scss create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.spec.ts create mode 100644 device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.ts create mode 100644 device-management-ui/src/app/components/devices/device-list/device-list.component.html create mode 100644 device-management-ui/src/app/components/devices/device-list/device-list.component.scss create mode 100644 device-management-ui/src/app/components/devices/device-list/device-list.component.spec.ts create mode 100644 device-management-ui/src/app/components/devices/device-list/device-list.component.ts create mode 100644 device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.html create mode 100644 device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.scss create mode 100644 device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.spec.ts create mode 100644 device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.ts create mode 100644 device-management-ui/src/app/components/loading-spinner/loader-spinner.component.scss create mode 100644 device-management-ui/src/app/components/loading-spinner/loader-spinner.component.spec.ts create mode 100644 device-management-ui/src/app/components/loading-spinner/loader-spinner.component.ts create mode 100644 device-management-ui/src/app/components/loading-spinner/loading-spinner.component.html create mode 100644 device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.html create mode 100644 device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.scss create mode 100644 device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.ts create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.html create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.scss create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.ts create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.html create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.scss create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.ts create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.html create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.scss create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.ts create mode 100644 device-management-ui/src/app/components/modals/delete/delete.component.html create mode 100644 device-management-ui/src/app/components/modals/delete/delete.component.scss create mode 100644 device-management-ui/src/app/components/modals/delete/delete.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/delete/delete.component.ts create mode 100644 device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.html create mode 100644 device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.scss create mode 100644 device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.ts create mode 100644 device-management-ui/src/app/components/modals/modal-head/modal-head.component.html create mode 100644 device-management-ui/src/app/components/modals/modal-head/modal-head.component.scss create mode 100644 device-management-ui/src/app/components/modals/modal-head/modal-head.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/modal-head/modal-head.component.ts create mode 100644 device-management-ui/src/app/components/modals/select-devices/select-devices.component.html create mode 100644 device-management-ui/src/app/components/modals/select-devices/select-devices.component.scss create mode 100644 device-management-ui/src/app/components/modals/select-devices/select-devices.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/select-devices/select-devices.component.ts create mode 100644 device-management-ui/src/app/components/modals/send-command/send-command.component.html create mode 100644 device-management-ui/src/app/components/modals/send-command/send-command.component.scss create mode 100644 device-management-ui/src/app/components/modals/send-command/send-command.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/send-command/send-command.component.ts create mode 100644 device-management-ui/src/app/components/modals/tenant/tenant-modal.component.html create mode 100644 device-management-ui/src/app/components/modals/tenant/tenant-modal.component.scss create mode 100644 device-management-ui/src/app/components/modals/tenant/tenant-modal.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/tenant/tenant-modal.component.ts create mode 100644 device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.html create mode 100644 device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.scss create mode 100644 device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.spec.ts create mode 100644 device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.ts create mode 100644 device-management-ui/src/app/components/pagination/pagination.component.html create mode 100644 device-management-ui/src/app/components/pagination/pagination.component.scss create mode 100644 device-management-ui/src/app/components/pagination/pagination.component.spec.ts create mode 100644 device-management-ui/src/app/components/pagination/pagination.component.ts create mode 100644 device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.html create mode 100644 device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.scss create mode 100644 device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.spec.ts create mode 100644 device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.ts create mode 100644 device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.html create mode 100644 device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.scss create mode 100644 device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.spec.ts create mode 100644 device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.ts create mode 100644 device-management-ui/src/app/components/toast-container/toast-container.component.html create mode 100644 device-management-ui/src/app/components/toast-container/toast-container.component.scss create mode 100644 device-management-ui/src/app/components/toast-container/toast-container.component.spec.ts create mode 100644 device-management-ui/src/app/components/toast-container/toast-container.component.ts create mode 100644 device-management-ui/src/app/models/authentication-value.ts create mode 100644 device-management-ui/src/app/models/command.ts create mode 100644 device-management-ui/src/app/models/config.ts create mode 100644 device-management-ui/src/app/models/credentials/credentials.ts create mode 100644 device-management-ui/src/app/models/credentials/secret.ts create mode 100644 device-management-ui/src/app/models/device.ts create mode 100644 device-management-ui/src/app/models/environment.ts create mode 100644 device-management-ui/src/app/models/state.ts create mode 100644 device-management-ui/src/app/models/tenant.ts create mode 100644 device-management-ui/src/app/prototypes/string-prototype.d.ts create mode 100644 device-management-ui/src/app/prototypes/string-prototype1.ts create mode 100644 device-management-ui/src/app/routing/app-routing.module.ts create mode 100644 device-management-ui/src/app/services/api/api.service.spec.ts create mode 100644 device-management-ui/src/app/services/api/api.service.ts create mode 100644 device-management-ui/src/app/services/command/command.service.spec.ts create mode 100644 device-management-ui/src/app/services/command/command.service.ts create mode 100644 device-management-ui/src/app/services/config/config.service.spec.ts create mode 100644 device-management-ui/src/app/services/config/config.service.ts create mode 100644 device-management-ui/src/app/services/credentials/credentials.service.spec.ts create mode 100644 device-management-ui/src/app/services/credentials/credentials.service.ts create mode 100644 device-management-ui/src/app/services/device/device.service.spec.ts create mode 100644 device-management-ui/src/app/services/device/device.service.ts create mode 100644 device-management-ui/src/app/services/google/google.service.ts create mode 100644 device-management-ui/src/app/services/loading-spinner/loading-spinner.service.spec.ts create mode 100644 device-management-ui/src/app/services/loading-spinner/loading-spinner.service.ts create mode 100644 device-management-ui/src/app/services/notification/notification.service.spec.ts create mode 100644 device-management-ui/src/app/services/notification/notification.service.ts create mode 100644 device-management-ui/src/app/services/sortable-table/sortable-table.directive.spec.ts create mode 100644 device-management-ui/src/app/services/sortable-table/sortable-table.directive.ts create mode 100644 device-management-ui/src/app/services/sortable-table/sortable-table.service.spec.ts create mode 100644 device-management-ui/src/app/services/sortable-table/sortable-table.service.ts create mode 100644 device-management-ui/src/app/services/states/states.service.spec.ts create mode 100644 device-management-ui/src/app/services/states/states.service.ts create mode 100644 device-management-ui/src/app/services/tenant/tenant.service.spec.ts create mode 100644 device-management-ui/src/app/services/tenant/tenant.service.ts create mode 100644 device-management-ui/src/app/shared/loader.interceptor.spec.ts create mode 100644 device-management-ui/src/app/shared/loader.interceptor.ts create mode 100644 device-management-ui/src/app/shared/search-filter.pipe.spec.ts create mode 100644 device-management-ui/src/app/shared/search-filter.pipe.ts create mode 100644 device-management-ui/src/app/shared/truncate.pipe.spec.ts create mode 100644 device-management-ui/src/app/shared/truncate.pipe.ts create mode 100644 device-management-ui/src/assets/_variables.scss create mode 100644 device-management-ui/src/assets/env.js create mode 100644 device-management-ui/src/assets/env.template.js create mode 100644 device-management-ui/src/assets/images/favicon.ico create mode 100644 device-management-ui/src/assets/images/logo_300px276px.png create mode 100644 device-management-ui/src/assets/images/sort-solid.svg create mode 100644 device-management-ui/src/assets/images/sort-up-solid.svg create mode 100644 device-management-ui/src/assets/scss/_breadcrumb.scss create mode 100644 device-management-ui/src/assets/scss/_buttons.scss create mode 100644 device-management-ui/src/assets/scss/_config-accordion.scss create mode 100644 device-management-ui/src/assets/scss/_date-time-picker.scss create mode 100644 device-management-ui/src/assets/scss/_layout.scss create mode 100644 device-management-ui/src/assets/scss/_lists.scss create mode 100644 device-management-ui/src/assets/scss/_loader.scss create mode 100644 device-management-ui/src/assets/scss/_login.scss create mode 100644 device-management-ui/src/assets/scss/_logo.scss create mode 100644 device-management-ui/src/assets/scss/_modals.scss create mode 100644 device-management-ui/src/assets/scss/_pagination.scss create mode 100644 device-management-ui/src/assets/scss/_responsive.scss create mode 100644 device-management-ui/src/assets/scss/_toasts.scss create mode 100644 device-management-ui/src/environments/environment.development.ts create mode 100644 device-management-ui/src/environments/environment.ts create mode 100644 device-management-ui/src/index.html create mode 100644 device-management-ui/src/main.ts create mode 100644 device-management-ui/src/styles.scss create mode 100644 device-management-ui/tsconfig.app.json create mode 100644 device-management-ui/tsconfig.json create mode 100644 device-management-ui/tsconfig.spec.json diff --git a/.gitignore b/.gitignore index 6c5bcd14..60977396 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ # OS files .DS_Store +/node_modules diff --git a/README.md b/README.md index ed9bff77..c42d1160 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,12 @@ registry type to another. ## Device communication API See the [device-communication](device-communication) directory for api specifications and implementation. + +## Device Management User Interface + +See the [device-management-ui](device-management-ui) directory for an example User Interface containing a list +of tenants with the option to create, update and delete a tenant. Each tenant contains a list of its devices with the +option to create and delete a device. Furthermore, the credentials of a device can be listed, created and deleted. +Currently only [Password Credentials](https://www.eclipse.org/hono/docs/concepts/device-identity/#usernamepassword-based-authentication) +and [RPK Credentials](https://www.eclipse.org/hono/docs/concepts/device-identity/#json-web-token-based-authentication) +are supported. This UI also contains the functionality to send a configuration or command through the [Device Communication API](device-communication). diff --git a/device-management-ui/.editorconfig b/device-management-ui/.editorconfig new file mode 100644 index 00000000..59d9a3a3 --- /dev/null +++ b/device-management-ui/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/device-management-ui/.gitignore b/device-management-ui/.gitignore new file mode 100644 index 00000000..811b4d5b --- /dev/null +++ b/device-management-ui/.gitignore @@ -0,0 +1,42 @@ +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db + +package-lock.json diff --git a/device-management-ui/Dockerfile b/device-management-ui/Dockerfile new file mode 100644 index 00000000..386c1f64 --- /dev/null +++ b/device-management-ui/Dockerfile @@ -0,0 +1,24 @@ +# STAGE 1: Build app +FROM node:16-alpine as build + +# Make /app as working directory +WORKDIR /app + +COPY ./src /app/src/ +COPY *.* /app/ + +# Install all the dependencies +RUN npm install && npm run build + +# STAGE 2: Serve app with NgInx +FROM nginx:alpine + +# Copy dist folder from build stage to nginx public folder +COPY --from=build /app/dist/device-management-ui /usr/share/nginx/html +COPY ./nginx.conf /etc/nginx/conf.d/default.conf + +# Start NgInx service +CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/assets/env.template.js > /usr/share/nginx/html/assets/env.js && exec nginx -g 'daemon off;'"] + +# Expose port 80 +EXPOSE 80 diff --git a/device-management-ui/README.md b/device-management-ui/README.md new file mode 100644 index 00000000..bc56f1e2 --- /dev/null +++ b/device-management-ui/README.md @@ -0,0 +1,65 @@ +# Device Management UI + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.2.0. This is an example +implementation for a Google based deployment in order to give users the possibility to operate with the [Device Registry +API](https://www.eclipse.org/hono/docs/api/management/) as well as with the [Device Communication API](../device-communication). +We are recommending using Identity-Aware Proxy ([IAP](https://cloud.google.com/iap/docs)) for your API security. Make +sure to configure the [OAuth consent](https://developers.google.com/workspace/guides/configure-oauth-consent) screen. +This UI will provide the possibility to: + +1. Access the Device Registry API containing actions like: + - listing tenants + - creating a new tenant with an ID and messaging-type + - deleting a tenant + - updating a tenant with another messaging-type + - listing devices of a tenant + - creating a new device with an ID + - deleting a device + - listing credentials of a device + - creating [Username/Password](https://www.eclipse.org/hono/docs/concepts/device-identity/#usernamepassword-based-authentication) or [JSON Web Token](https://www.eclipse.org/hono/docs/concepts/device-identity/#json-web-token-based-authentication) based credentials + - updating JSON Web Token based credentials + - deleting credentials + + +2. Access the Device Communication API containing actions like: + - listing configs + - updating a config + - listing states + - sending a command + +**_NOTE:_** This UI cannot be run without further adjustments! If one wants to use this UI in other environments than on +Google Cloud, adjustments have to be made +to **not** include the GoogleService and to update the url suffixes of the services. + +### Development server + +The development server uses the Proxy Configuration file [proxy.config.json](proxy.config.json). So the target address +must be updated +with the address the Hono API is hosted. To run the dev server `ng serve` must be executed. The UI can be then accessed +via `http://localhost:4200/`. +The application will automatically reload if any of the source files is changed. + +#### Google APIs + +If the Device Registry API and/or the Device Communication API is hosted in GCP with Identity Aware Proxy, +the Google Client ID of the enabled IAP must be provided in the environment +file [environment.development.ts](../device-management-ui/src/environments/environment.development.ts). + +### Build + +#### Google APIs + +If the UI, the Device Registry API and/or the Device Communication API is hosted in GCP with Identity Aware Proxy, +the Google Client ID of the enabled IAP must be provided inside an environment variable `ENV_GOOGLE_CLIENT_ID`. +This environment variable is then set as `googleClientId` inside the environment +file [environment.ts](../device-management-ui/src/environments/environment.ts). + +#### Docker Image + +A docker image can be built by executing: `docker build -t {REGISTRY}/{IMAGE_NAME}:{TAG} .`. +The docker image is installing all the dependencies and building the project with ng build automatically. +Also, a nginx based image is used for building the server. + +### Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). diff --git a/device-management-ui/angular.json b/device-management-ui/angular.json new file mode 100644 index 00000000..8230df16 --- /dev/null +++ b/device-management-ui/angular.json @@ -0,0 +1,122 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "device-management-ui": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/device-management-ui", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets", + "src/assets/env.js" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "2mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "browserTarget": "device-management-ui:build:production" + }, + "development": { + "browserTarget": "device-management-ui:build:development", + "proxyConfig": "proxy.config.json" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "device-management-ui:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "codeCoverage": true, + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ], + "karmaConfig": "karma.conf.js" + } + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/device-management-ui/karma.conf.js b/device-management-ui/karma.conf.js new file mode 100644 index 00000000..7b11c7d4 --- /dev/null +++ b/device-management-ui/karma.conf.js @@ -0,0 +1,34 @@ +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + jasmine: { + random: false, + verboseDeprecations: true + }, + clearContext: false + }, + jasmineHtmlReporter: { + suppressAll: true + }, + coverageReporter: { + dir: require('path').join(__dirname, './coverage/device-management-ui'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' } + ] + }, + reporters: ['progress', 'kjhtml'], + browsers: ['Chrome'], + restartOnFileChange: true + }); +}; diff --git a/device-management-ui/nginx.conf b/device-management-ui/nginx.conf new file mode 100644 index 00000000..210d761c --- /dev/null +++ b/device-management-ui/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + index index.html index.htm; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + +} diff --git a/device-management-ui/package.json b/device-management-ui/package.json new file mode 100644 index 00000000..0ee24d52 --- /dev/null +++ b/device-management-ui/package.json @@ -0,0 +1,47 @@ +{ + "name": "device-management-ui", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^15.2.0", + "@angular/common": "^15.2.0", + "@angular/compiler": "^15.2.0", + "@angular/core": "^15.2.0", + "@angular/forms": "^15.2.0", + "@angular/platform-browser": "^15.2.0", + "@angular/platform-browser-dynamic": "^15.2.0", + "@angular/router": "^15.2.0", + "@fortawesome/angular-fontawesome": "^0.12.1", + "@fortawesome/free-solid-svg-icons": "^6.3.0", + "@ng-bootstrap/ng-bootstrap": "^14.0.1", + "@ng-select/ng-select": "^10.0.3", + "angular-oauth2-oidc": "^15.0.1", + "angular-oauth2-oidc-jwks": "^15.0.1", + "bootstrap": "^5.3.0-alpha1", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.12.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^15.2.0", + "@angular/cli": "~15.2.0", + "@angular/compiler-cli": "^15.2.0", + "@angular/localize": "^15.2.0", + "@types/jasmine": "~4.3.0", + "@types/lodash": "^4.14.197", + "jasmine-core": "~4.5.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.1.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.0.0", + "typescript": "~4.9.4" + } +} diff --git a/device-management-ui/proxy.config.json b/device-management-ui/proxy.config.json new file mode 100644 index 00000000..426f4b79 --- /dev/null +++ b/device-management-ui/proxy.config.json @@ -0,0 +1,9 @@ +{ + "/v1/*": { + "target": "https://hono.eclipseprojects.io:28443", + "secure": false, + "logLevel": "debug", + "changeOrigin": true + } +} + diff --git a/device-management-ui/src/app/app.component.html b/device-management-ui/src/app/app.component.html new file mode 100644 index 00000000..9d33628b --- /dev/null +++ b/device-management-ui/src/app/app.component.html @@ -0,0 +1,9 @@ +
+
+ +
+
+ + + + diff --git a/device-management-ui/src/app/app.component.scss b/device-management-ui/src/app/app.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/app.component.spec.ts b/device-management-ui/src/app/app.component.spec.ts new file mode 100644 index 00000000..645c8629 --- /dev/null +++ b/device-management-ui/src/app/app.component.spec.ts @@ -0,0 +1,43 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {RouterModule} from "@angular/router"; +import {LoaderSpinnerComponent} from "./components/loading-spinner/loader-spinner.component"; +import {ToastContainerComponent} from "./components/toast-container/toast-container.component"; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), RouterModule, ], + declarations: [AppComponent, LoaderSpinnerComponent, ToastContainerComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have as title 'device-management-ui'`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('device-management-ui'); + }); +}); diff --git a/device-management-ui/src/app/app.component.ts b/device-management-ui/src/app/app.component.ts new file mode 100644 index 00000000..458de961 --- /dev/null +++ b/device-management-ui/src/app/app.component.ts @@ -0,0 +1,30 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { Component } from '@angular/core'; +import './prototypes/string-prototype1' +import {GoogleService} from "./services/google/google.service"; +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { + title = 'device-management-ui'; + + constructor(private googleService: GoogleService) { + } + +} diff --git a/device-management-ui/src/app/app.module.ts b/device-management-ui/src/app/app.module.ts new file mode 100644 index 00000000..bb0a5ef7 --- /dev/null +++ b/device-management-ui/src/app/app.module.ts @@ -0,0 +1,118 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {NgModule} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; + +import {AppComponent} from './app.component'; +import {AppRoutingModule} from './routing/app-routing.module'; + +import {TenantListComponent} from './components/tenants/tenant-list/tenant-list.component'; +import {TenantDetailComponent} from './components/tenants/tenant-detail/tenant-detail.component'; +import {DeviceListComponent} from './components/devices/device-list/device-list.component'; +import {DeviceDetailComponent} from './components/devices/device-detail/device-detail.component'; +import {TenantModalComponent} from './components/modals/tenant/tenant-modal.component'; +import {DeleteComponent} from './components/modals/delete/delete.component'; + +import {HTTP_INTERCEPTORS, HttpClientModule} from "@angular/common/http"; +import {UpdateConfigModalComponent} from './components/modals/update-config-modal/update-config-modal.component'; +import {SendCommandComponent} from './components/modals/send-command/send-command.component'; +import {ListConfigComponent} from './components/devices/device-detail/list-config/list-config.component'; +import {ListStateComponent} from './components/devices/device-detail/list-state/list-state.component'; +import {FormsModule} from "@angular/forms"; +import {NgSelectModule} from "@ng-select/ng-select"; +import {ModalFooterComponent} from './components/modals/modal-footer/modal-footer.component'; +import {ModalHeadComponent} from './components/modals/modal-head/modal-head.component'; +import { + DevicePasswordModalComponent +} from './components/modals/credentials-modal/device-password-modal/device-password-modal.component'; +import { + DeviceRpkModalComponent +} from './components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component'; +import {SearchFilterPipe} from './shared/search-filter.pipe'; +import {DateTimePickerComponent} from './components/date-time/date-time-picker.component'; +import {DatePipe} from "@angular/common"; +import { + ListAuthenticationComponent +} from './components/devices/device-detail/list-authentication/list-authentication.component'; +import {CredentialsModalComponent} from './components/modals/credentials-modal/credentials-modal.component'; +import {LoaderSpinnerComponent} from './components/loading-spinner/loader-spinner.component'; +import {LoadingSpinnerService} from './services/loading-spinner/loading-spinner.service'; +import {LoaderInterceptor} from './shared/loader.interceptor'; +import {OAuthModule} from "angular-oauth2-oidc"; +import {SortableTableDirective} from './services/sortable-table/sortable-table.directive'; +import {FaIconLibrary, FontAwesomeModule} from "@fortawesome/angular-fontawesome"; +import {fas} from "@fortawesome/free-solid-svg-icons"; +import {ToastContainerComponent} from './components/toast-container/toast-container.component'; +import {TruncatePipe} from './shared/truncate.pipe'; +import {GatewayListComponent} from './components/gateways/gateway-list/gateway-list.component'; +import { SelectDevicesComponent } from './components/modals/select-devices/select-devices.component'; +import { CreateAndBindModalComponent } from './components/modals/create-and-bind-modal/create-and-bind-modal.component'; +import { PaginationComponent } from './components/pagination/pagination.component'; + +@NgModule({ + declarations: [ + AppComponent, + TenantListComponent, + TenantDetailComponent, + DeviceListComponent, + DeviceDetailComponent, + TenantModalComponent, + DeleteComponent, + UpdateConfigModalComponent, + SendCommandComponent, + ListConfigComponent, + ListStateComponent, + ModalFooterComponent, + ModalHeadComponent, + DevicePasswordModalComponent, + DeviceRpkModalComponent, + SearchFilterPipe, + DateTimePickerComponent, + ListAuthenticationComponent, + CredentialsModalComponent, + LoaderSpinnerComponent, + SortableTableDirective, + ToastContainerComponent, + TruncatePipe, + GatewayListComponent, + SelectDevicesComponent, + CreateAndBindModalComponent, + PaginationComponent + ], + imports: [ + BrowserModule, + NgbModule, + AppRoutingModule, + HttpClientModule, + NgSelectModule, + FormsModule, + OAuthModule.forRoot(), + FontAwesomeModule + ], + providers: [ + DatePipe, + LoadingSpinnerService, + {provide: HTTP_INTERCEPTORS, useClass: LoaderInterceptor, multi: true}, + ], + bootstrap: [AppComponent] +}) +export class AppModule { + + constructor(library: FaIconLibrary) { + library.addIconPacks(fas); + } +} diff --git a/device-management-ui/src/app/components/date-time/date-time-picker.component.html b/device-management-ui/src/app/components/date-time/date-time-picker.component.html new file mode 100644 index 00000000..6044b60d --- /dev/null +++ b/device-management-ui/src/app/components/date-time/date-time-picker.component.html @@ -0,0 +1,42 @@ +
+ + + + + +
+
+
Set time
+
+
+ + +
+
+
+
+ + + +
+
+
+ +
+
diff --git a/device-management-ui/src/app/components/date-time/date-time-picker.component.scss b/device-management-ui/src/app/components/date-time/date-time-picker.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/date-time/date-time-picker.component.spec.ts b/device-management-ui/src/app/components/date-time/date-time-picker.component.spec.ts new file mode 100644 index 00000000..c8a10082 --- /dev/null +++ b/device-management-ui/src/app/components/date-time/date-time-picker.component.spec.ts @@ -0,0 +1,84 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {DateTimePickerComponent} from './date-time-picker.component'; +import {NgbCalendar, NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {FormsModule} from "@angular/forms"; + +describe('DateTimePickerComponent', () => { + let component: DateTimePickerComponent; + let fixture: ComponentFixture; + let calendarSpy: jasmine.SpyObj; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgbModule, FontAwesomeTestingModule, FormsModule], + declarations: [DateTimePickerComponent], + }) + .compileComponents(); + + calendarSpy = TestBed.inject(NgbCalendar) as jasmine.SpyObj; + fixture = TestBed.createComponent(DateTimePickerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit date and time on onFocusOut', () => { + spyOn(component.dateTime, 'emit'); + + component.onFocusOut(); + + expect(component.dateTime.emit).toHaveBeenCalledWith({ + date: component['date'], + time: component['time'], + }); + }); + + it('should set date and time from secretDate input', () => { + component.secretDate = '2023-05-15T10:00:00.000Z'; + + component.ngOnInit(); + + expect(component['date']).toEqual({ day: 15, month: 5, year: 2023 }); + expect(component['time']).toEqual({ hour: 10, minute: 0, second: 0 }); + }); + + it('should set date to today and emit date and time when secretDate is not provided', () => { + spyOn(component, 'onFocusOut'); + + component.secretDate = undefined; + fixture.detectChanges(); + + component.ngOnInit(); + expect(component['date']).toEqual(component['calendar'].getToday()); + expect(component['time']).toEqual({hour: 0, minute: 0, second: 0}); + expect(component.onFocusOut).toHaveBeenCalled(); + }); + + it('should return correct time string', () => { + const time = {hour: 12, minute: 30, second: 0}; + + const result = component['getTimeString'](time); + expect(result).toEqual('12:30:00'); + }); + +}); diff --git a/device-management-ui/src/app/components/date-time/date-time-picker.component.ts b/device-management-ui/src/app/components/date-time/date-time-picker.component.ts new file mode 100644 index 00000000..0bacec62 --- /dev/null +++ b/device-management-ui/src/app/components/date-time/date-time-picker.component.ts @@ -0,0 +1,79 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {NgbCalendar, NgbDateStruct} from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: 'app-date-time-picker', + templateUrl: './date-time-picker.component.html', + styleUrls: ['./date-time-picker.component.scss'] +}) +export class DateTimePickerComponent implements OnInit { + + @Input() public secretDate: string | undefined = ''; + + @Output() public dateTime: EventEmitter = new EventEmitter(); + + public date: NgbDateStruct | any; + public time: any = {hour: 0, minute: 0, second: 0}; + public maxDate: NgbDateStruct = {year: new Date().getUTCFullYear() + 100, month: 12, day: 31}; + + constructor(private calendar: NgbCalendar) { + } + + private get timezoneOffset() { + const date = new Date(this.date.year, this.date.month, this.date.day); + date.setHours(Number(this.time.hour)); + date.setMinutes(Number(this.time.minute)); + date.setSeconds(this.time.second); + return date.getTimezoneOffset() / 60; + } + + public ngOnInit() { + if (this.secretDate) { + const date = new Date(this.secretDate); + this.date = {day: date.getUTCDate(), month: date.getUTCMonth() + 1, year: date.getUTCFullYear()}; + this.time = {hour: date.getUTCHours(), minute: date.getUTCMinutes(), second: date.getUTCSeconds()}; + } else { + this.date = this.calendar.getToday(); + this.onFocusOut(); + } + } + + public onFocusOut() { + this.dateTime.emit({ + date: this.date, + time: this.time + }); + } + + public showTimezoneOffsetMessage(): boolean { + return this.timezoneOffset !== 0; + } + + public getTimezoneOffsetMessage() { + return 'Your timezone differ to UTC time, please be aware that the UTC time (%h hour) will be taken.' + .replace('%h', String(this.timezoneOffset)); + } + + public getTimeString(time: any): string { + const paddedHour = String(time.hour).padStart(2, '0'); + const paddedMinute = String(time.minute).padStart(2, '0'); + const paddedSecond = String(time.second).padStart(2, '0'); + + return `${paddedHour}:${paddedMinute}:${paddedSecond}`; + } +} diff --git a/device-management-ui/src/app/components/devices/device-detail/device-detail.component.html b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.html new file mode 100644 index 00000000..87771c2b --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.html @@ -0,0 +1,146 @@ + + + + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ Tenant ID: +
+
+ +
+
+ +
+
+ Via: +
+
+ +
+
+ +
+
+ Created (UTC): +
+
+ +
+
+
+ +
+ +
+ +
+ +
diff --git a/device-management-ui/src/app/components/devices/device-detail/device-detail.component.scss b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.scss new file mode 100644 index 00000000..35ab9b02 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.scss @@ -0,0 +1,12 @@ +.detail-label { + min-width: 100px; + + > span { + font-weight: bold; + } +} + +// Tab nav +.nav-link.nav-item.active { + border-bottom: 2px solid var(--theme); +} \ No newline at end of file diff --git a/device-management-ui/src/app/components/devices/device-detail/device-detail.component.spec.ts b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.spec.ts new file mode 100644 index 00000000..7ee848c0 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.spec.ts @@ -0,0 +1,82 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {DeviceDetailComponent} from './device-detail.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {ListConfigComponent} from "./list-config/list-config.component"; +import {Router} from "@angular/router"; +import {RouterTestingModule} from "@angular/router/testing"; +import {DatePipe} from "@angular/common"; + +describe('DeviceDetailComponent', () => { + let component: DeviceDetailComponent; + let fixture: ComponentFixture; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, NgbModule, RouterTestingModule], + declarations: [DeviceDetailComponent, ListConfigComponent], + providers: [DatePipe] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return the correct device detail', () => { + component['device'] = { + id: 'test-id', + status: {} + }; + expect(component['deviceDetail']).toEqual('Device: test-id'); + }); + + it('should return transformed date when status["created"] is in place', () => { + const status = {created: '2023-06-14T10:30:00Z'}; + + const result = component['getCreationTime'](status); + expect(result).toEqual('Jun 14, 2023, 10:30:00 AM'); + }); + + it('should return "-" when status["created"] is not in place', () => { + const status = {name: '12345'}; + + const result = component['getCreationTime'](status); + expect(result).toEqual('-'); + }); + + it('should navigate back to tenant detail page with state', () => { + component['tenant'] = {id: 'test-id', ext: 'tenant-test'}; + + component['navigateBack'](); + expect(router.navigate).toHaveBeenCalledWith(['tenant-detail', 'test-id'], { + state: {tenant: component['tenant']}, + }); + }); + +}); diff --git a/device-management-ui/src/app/components/devices/device-detail/device-detail.component.ts b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.ts new file mode 100644 index 00000000..4c05c8ef --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/device-detail.component.ts @@ -0,0 +1,285 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component} from '@angular/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {DeleteComponent} from '../../modals/delete/delete.component'; +import {Device} from "../../../models/device"; +import { Router} from "@angular/router"; +import {Tenant} from "../../../models/tenant"; +import {UpdateConfigModalComponent} from '../../modals/update-config-modal/update-config-modal.component'; +import {SendCommandComponent} from '../../modals/send-command/send-command.component'; +import {DeviceService} from "../../../services/device/device.service"; +import {ConfigService} from "../../../services/config/config.service"; +import {Config} from "../../../models/config"; +import {CredentialsModalComponent} from '../../modals/credentials-modal/credentials-modal.component'; +import {Credentials} from 'src/app/models/credentials/credentials'; +import {CredentialsService} from "../../../services/credentials/credentials.service"; +import {NotificationService} from "../../../services/notification/notification.service"; +import {DatePipe, Location} from "@angular/common"; +import {CreateAndBindModalComponent} from "../../modals/create-and-bind-modal/create-and-bind-modal.component"; + +@Component({ + selector: 'app-device-detail', + templateUrl: './device-detail.component.html', + styleUrls: ['./device-detail.component.scss'] +}) +export class DeviceDetailComponent { + + public isGateway: boolean = false; + public device: Device = new Device(); + public gateway: Device = new Device(); + public tenant: Tenant = new Tenant(); + public devices: Device[] = []; + public boundDevicesList: Device[] = []; + public configs: Config[] = []; + public credentials: Credentials[] = []; + public deviceListCount: number = 0; + public boundDeviceListCount: number = 0; + public pageSize: number = 50; + public isBoundDevice: boolean = false; + + private pageOffset: number = 0; + + constructor(private modalService: NgbModal, + private router: Router, + private location: Location, + private deviceService: DeviceService, + private configService: ConfigService, + private credentialsService: CredentialsService, + private notificationService: NotificationService, + private datePipe: DatePipe) { + const navigation = this.router.getCurrentNavigation(); + let accessedViaUrl: boolean = false; + if (navigation) { + const state = navigation.extras.state + if (state && state['tenant']) { + this.tenant = state['tenant']; + } + if (state && state['device']) { + this.device = state['device']; + this.setDevices(); + this.setIsGatewayFlag(); + } else { + accessedViaUrl = true; + const urlSegments = window.location.pathname.substring(1).split("/"); + const tenantId = String(urlSegments[1]); + const deviceId = String(urlSegments[2]); + this.deviceService.getByExactId(tenantId, deviceId).subscribe({ + next: (result) => { + result.id = deviceId; + this.tenant = new Tenant(); + this.tenant.id = tenantId; + this.device = result; + this.setDevices(); + this.setIsGatewayFlag(); + this.setUpDeviceDetail(); + } + }) + } + + } + if (!accessedViaUrl) { + this.setUpDeviceDetail(); + } + } + + private setUpDeviceDetail() { + if(this.isGateway) { + this.gateway = this.device; + } + this.getConfigs(); + this.getCredentials(); + this.checkIsBoundDevice(); + } + private setIsGatewayFlag() { + const storedIsGateway = localStorage.getItem('isGateway_' + this.device.id); + this.isGateway = storedIsGateway ? JSON.parse(storedIsGateway) : this.deviceService.getActiveTab(); + localStorage.setItem('isGateway_' + this.device.id, JSON.stringify(this.isGateway)); + } + + + public ngOnDestroy(){ + localStorage.removeItem('isGateway_' + this.device.id) + } + + protected get deviceDetail() { + if (this.isGateway) { + return 'Gateway: ' + this.device.id + } else { + return 'Device: ' + this.device.id; + } + } + + protected get idLabel() { + if (this.isGateway) { + return 'Gateway ID: '; + } else { + return 'Device ID: '; + } + } + + protected getCreationTime(status: any) { + if (status && status['created']) { + return this.datePipe.transform(status['created'], 'medium', 'UTC') + } + return '-'; + } + + protected deleteDevice(): void { + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Confirm Delete'; + modalRef.componentInstance.body = 'Do you really want to delete the device ' + this.device.id.toBold() + ' ?'; + modalRef.result.then((res) => { + if (res) { + this.delete(); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + protected navigateBack() { + this.router.navigate(['tenant-detail', this.tenant.id], { + state: { + tenant: this.tenant + } + }); + } + + protected updateConfig(): void { + const modalRef = this.modalService.open(UpdateConfigModalComponent, {size: 'lg'}); + modalRef.componentInstance.deviceId = this.device.id; + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.result.then((res) => { + if (res) { + this.notificationService.success('Successfully updated config for device ' + this.device.id.toBold()); + this.configs = [res, ...this.configs]; + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + protected sendCommand(): void { + const modalRef = this.modalService.open(SendCommandComponent, {size: 'lg'}); + modalRef.componentInstance.deviceId = this.device.id; + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.result.then((res) => { + if (res) { + this.notificationService.success('Successfully sent command to device ' + this.device.id.toBold()); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + protected addAuthentication() { + const modalRef = this.modalService.open(CredentialsModalComponent, {size: 'lg'}); + modalRef.componentInstance.deviceId = this.device.id; + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.componentInstance.credentials = this.credentials; + modalRef.result.then((res) => { + if (res) { + this.notificationService.success('Successfully added credentials to device ' + this.device.id.toBold()); + this.getCredentials(); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + private delete() { + this.deviceService.delete(this.device, this.tenant.id).subscribe(() => { + if(this.isGateway) { + this.deviceService.listBoundDevices(this.tenant.id, this.gateway.id, this.pageSize, this.pageOffset).subscribe((deviceList) => { + const boundDevices = deviceList.result; + boundDevices.forEach((boundDevice: Device) => { + if (boundDevice.via != null) { + const index = boundDevice.via.indexOf(this.gateway.id) + if (index >= 0) { + boundDevice.via.splice(index, 1); + this.deviceService.update(boundDevice, this.tenant.id).subscribe((result) => { + console.log('update result: ', result); + }); + } + } + }); + }); + } + this.notificationService.success('Successfully deleted device ' + this.device.id.toBold()); + this.navigateBack(); + }, (error) => { + console.log('Error deleting device', error); + this.notificationService.error('Could not delete device ' + this.device.id.toBold()); + }) + } + + private getConfigs() { + this.configService.list(this.device.id, this.tenant.id).subscribe((configs) => { + this.configs = configs.deviceConfigs; + }, (error) => { + console.log('Error receiving configs for device', error); + }); + } + + private getCredentials() { + this.credentials = []; + this.credentialsService.list(this.device.id, this.tenant.id).subscribe((credentials) => { + if (credentials && credentials.length > 0) { + this.credentials = [...credentials]; + } + }, (error) => { + console.log('Error receiving credentials for device', error); + }); + } + + protected bindNewDevicesToGateway(){ + const modalRef = this.modalService.open(CreateAndBindModalComponent, {size: 'lg'}); + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.componentInstance.deviceId = this.device.id; + modalRef.componentInstance.isBindDeviceFlag = true; + modalRef.componentInstance.boundDevicesCount = this.boundDeviceListCount; + modalRef.componentInstance.isGateway = this.isGateway; + + modalRef.componentInstance.devicesSelected.subscribe((selectedDevices: Device[]) => { + this.boundDevicesList.push(...selectedDevices); + }); + } + + private setDevices() { + this.deviceService.listAll(this.tenant.id, this.pageSize, this.pageOffset).subscribe((listResult) => { + this.devices = listResult.result; + this.deviceListCount = listResult.total; + }, (error) => { + console.log(error); + }); + } + + protected setBoundDevices() { + this.deviceService.listBoundDevices(this.tenant.id, this.device.id, this.pageSize, this.pageOffset).subscribe((listResult) => { + this.boundDevicesList = listResult.result; + this.boundDeviceListCount = listResult.total; + }, (error) => { + console.log(error); + }); + } + + protected checkIsBoundDevice(){ + if(this.device.via != undefined) { + this.isBoundDevice = true; + } + } +} diff --git a/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.html b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.html new file mode 100644 index 00000000..e7bbbca8 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + +
+ Authentication type + + Auth ID + + Secret ID + + Expiry time (UTC) + + Actions +
+ + + + + + + + +
+ +
+ +
diff --git a/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.scss b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.scss new file mode 100644 index 00000000..43a764b4 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.scss @@ -0,0 +1,3 @@ +.auth-btn-wrapper { + float: left; +} \ No newline at end of file diff --git a/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.spec.ts b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.spec.ts new file mode 100644 index 00000000..ac0287b9 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.spec.ts @@ -0,0 +1,70 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ListAuthenticationComponent} from './list-authentication.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {CredentialTypes} from "../../../../models/credentials/credentials"; + +describe('ListAuthenticationComponent', () => { + let component: ListAuthenticationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot()], + declarations: [ ListAuthenticationComponent ], + }) + .compileComponents(); + + fixture = TestBed.createComponent(ListAuthenticationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return true for RPK credentials', () => { + const authenticationValue = {type: CredentialTypes.RPK}; + expect(component['isEditable'](authenticationValue)).toBe(true); + }); + + it('should return false for HASHED_PASSWORD credentials', () => { + const authenticationValue = {type: CredentialTypes.HASHED_PASSWORD}; + expect(component['isEditable'](authenticationValue)).toBe(false); + }); + + it('should return "-" when type is undefined', () => { + const result = component['getAuthenticationType'](undefined); + + expect(result).toEqual('-'); + }); + + it('should return "JWT based" when type is RPK', () => { + const result = component['getAuthenticationType'](CredentialTypes.RPK); + + expect(result).toEqual('JWT based'); + }); + + it('should return "Password based" when type is HASHED_PASSWORD', () => { + const result = component['getAuthenticationType'](CredentialTypes.HASHED_PASSWORD); + + expect(result).toEqual('Password based'); + }); + +}); diff --git a/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.ts b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.ts new file mode 100644 index 00000000..dabe8d18 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-authentication/list-authentication.component.ts @@ -0,0 +1,170 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core'; +import {Credentials, CredentialTypes} from "../../../../models/credentials/credentials"; +import {AuthenticationValue} from "../../../../models/authentication-value"; +import {CredentialsService} from "../../../../services/credentials/credentials.service"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {CredentialsModalComponent} from "../../../modals/credentials-modal/credentials-modal.component"; +import {DeleteComponent} from "../../../modals/delete/delete.component"; +import {NotificationService} from "../../../../services/notification/notification.service"; + +@Component({ + selector: 'app-list-authentication', + templateUrl: './list-authentication.component.html', + styleUrls: ['./list-authentication.component.scss'] +}) +export class ListAuthenticationComponent implements OnInit, OnChanges { + + @Input() public deviceId: string = ''; + @Input() public tenantId: string = ''; + @Input() public credentials: Credentials[] = []; + + public authenticationValues: AuthenticationValue[] = []; + private authIdKey: string = 'auth-id'; + private notBeforeKey: string = 'not-before'; + private notAfterKey: string = 'not-after'; + + constructor(private modalService: NgbModal, + private credentialsService: CredentialsService, + private notificationService: NotificationService) { + } + + ngOnInit() { + this.setAuthenticationValues(this.credentials); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['credentials'].currentValue) { + this.setAuthenticationValues(this.credentials); + } + } + + protected isEditable(authenticationValue: AuthenticationValue): boolean { + return authenticationValue.type === CredentialTypes.RPK; + } + + protected edit(authenticationValue: AuthenticationValue) { + if (!this.isEditable(authenticationValue)) { + return; + } + const modalRef = this.modalService.open(CredentialsModalComponent, {size: 'lg'}); + modalRef.componentInstance.isNewCredentials = false; + modalRef.componentInstance.tenantId = this.tenantId; + modalRef.componentInstance.deviceId = this.deviceId; + modalRef.componentInstance.credential = this.findCredentials(authenticationValue); + modalRef.componentInstance.credentials = this.credentials; + modalRef.result.then((res) => { + if (res) { + this.notificationService.success("Successfully edited credentials of device " + this.deviceId.toBold()); + } + this.getCredentials(); + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + protected delete(authenticationValue: AuthenticationValue) { + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Confirm Delete'; + modalRef.componentInstance.body = 'Do you really want to delete the credentials ' + authenticationValue[this.authIdKey] + '?'; + modalRef.result.then((res) => { + if (res) { + const credential: Credentials = this.findCredentials(authenticationValue); + if (credential) { + const removed: boolean = this.removeCredential(credential); + if (removed) { + this.saveCredentials(authenticationValue); + } + } + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + protected getAuthenticationType(type: CredentialTypes | string | undefined): string { + if (!type) { + return '-'; + } + if (type == CredentialTypes.RPK) { + return 'JWT based' + } else { + return 'Password based'; + } + } + + private saveCredentials(authenticationValue: AuthenticationValue) { + this.credentialsService.save(this.deviceId, this.tenantId, this.credentials).subscribe(() => { + const index = this.authenticationValues.indexOf(authenticationValue); + if (index >= 0) { + this.authenticationValues.splice(index, 1); + this.notificationService.success("Successfully deleted credentials for device " + this.deviceId.toBold()); + } + }, (error) => { + console.log('Error saving credentials for device', this.deviceId, error); + this.notificationService.error("Could not delete credentials for device " + this.deviceId.toBold()); + }) + } + + private removeCredential(credential: Credentials): boolean { + const index = this.credentials.indexOf(credential); + if (index >= 0) { + this.credentials.splice(index, 1); + return true; + } + return false; + } + + private findCredentials(authenticationValue: AuthenticationValue): Credentials { + for (const credential of this.credentials) { + if (credential[this.authIdKey] === authenticationValue[this.authIdKey] && credential.secrets[0].id === authenticationValue.id) { + return credential; + } + } + return new Credentials(); + } + + private getCredentials() { + this.credentialsService.list(this.deviceId, this.tenantId).subscribe((credentials) => { + if (credentials && credentials.length > 0) { + this.credentials = credentials; + this.setAuthenticationValues(credentials); + } + }, (error) => { + console.log(error); + }) + } + + + private setAuthenticationValues(credentials: any[]) { + this.authenticationValues = []; + for (const c of credentials) { + for (const secret of c.secrets) { + const authenticationValue: AuthenticationValue = new AuthenticationValue(); + authenticationValue.id = secret.id; + authenticationValue.type = c.type; + authenticationValue[this.authIdKey] = c[this.authIdKey]; + authenticationValue[this.notAfterKey] = secret[this.notAfterKey]; + authenticationValue[this.notBeforeKey] = secret[this.notBeforeKey]; + authenticationValue.algorithm = secret.algorithm; + authenticationValue.key = secret.key; + + this.authenticationValues = [authenticationValue, ...this.authenticationValues]; + } + } + } +} diff --git a/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.html b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.html new file mode 100644 index 00000000..c3df70e1 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + +
+ Version + + Ack. + + Cloud update time (UTC) + + Data +
+ + + + + + + +
diff --git a/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.scss b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.scss new file mode 100644 index 00000000..31330d01 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.scss @@ -0,0 +1,14 @@ +fa-icon { + margin-right: 0.5em; + .fa-triangle-exclamation { + color: orange!important; + } + .fa-circle-check { + color: green!important; + } +} + +.centered { + text-align: center; + vertical-align: middle; +} \ No newline at end of file diff --git a/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.spec.ts b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.spec.ts new file mode 100644 index 00000000..56ff7d0b --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.spec.ts @@ -0,0 +1,62 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {ListConfigComponent} from './list-config.component'; +import {Config} from "../../../../models/config"; +import {faCircleCheck, faTriangleExclamation} from "@fortawesome/free-solid-svg-icons"; + +describe('ListConfigComponent', () => { + let component: ListConfigComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ListConfigComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ListConfigComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return circleCheck icon when deviceAckTime is defined', () => { + const config: Config = new Config(); + config.deviceAckTime = '123'; + expect(component.getDeviceAckTime(config)).toEqual(faCircleCheck) + }); + + it('should return triangleExclamation icon when deviceAckTime is not defined', () => { + const config: Config = new Config(); + expect(component.getDeviceAckTime(config)).toEqual(faTriangleExclamation) + }); + + it('should return green icon color when deviceAckTime is defined', () => { + const config: Config = new Config(); + config.deviceAckTime = '123'; + expect(component.iconColor(config)).toEqual('color: green') + }); + + it('should return orange icon color when deviceAckTime is not defined', () => { + const config: Config = new Config(); + expect(component.iconColor(config)).toEqual('color: orange') + }); +}); diff --git a/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.ts b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.ts new file mode 100644 index 00000000..32a9c6dc --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-config/list-config.component.ts @@ -0,0 +1,56 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input} from '@angular/core'; +import {Config} from "../../../../models/config"; +import {faCircleCheck, faTriangleExclamation} from '@fortawesome/free-solid-svg-icons'; +import {UpdateConfigModalComponent} from "../../../modals/update-config-modal/update-config-modal.component"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: 'app-list-config', + templateUrl: './list-config.component.html', + styleUrls: ['./list-config.component.scss'] +}) +export class ListConfigComponent { + + @Input() public configs: Config[] = []; + + constructor(private modalService: NgbModal) { + } + + public getDeviceAckTime(config: Config) { + if (config.deviceAckTime) { + return faCircleCheck; + } else { + return faTriangleExclamation; + } + } + + public iconColor(config: Config) { + if (config.deviceAckTime) { + return 'color: green'; + } else { + return 'color: orange'; + } + } + + showConfig(config: Config) { + const modalRef = this.modalService.open(UpdateConfigModalComponent, {size: 'lg'}); + modalRef.componentInstance.showConfig = true; + modalRef.componentInstance.savedConfig = config; + + } +} diff --git a/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.html b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.html new file mode 100644 index 00000000..29a9cda1 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + +
+ Cloud update time (UTC) + + Data +
+ + + +
diff --git a/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.scss b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.spec.ts b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.spec.ts new file mode 100644 index 00000000..16d808c7 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.spec.ts @@ -0,0 +1,41 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {ListStateComponent} from './list-state.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; + +describe('ListStateComponent', () => { + let component: ListStateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot()], + declarations: [ListStateComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ListStateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.ts b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.ts new file mode 100644 index 00000000..9c4aeb00 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-detail/list-state/list-state.component.ts @@ -0,0 +1,46 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input, OnInit} from '@angular/core'; +import {StatesService} from "../../../../services/states/states.service"; +import {State} from "../../../../models/state"; + +@Component({ + selector: 'app-list-state', + templateUrl: './list-state.component.html', + styleUrls: ['./list-state.component.scss'] +}) +export class ListStateComponent implements OnInit { + + @Input() public deviceId: string = ''; + @Input() public tenantId: string = ''; + + public states: State[] = []; + + constructor(private statesService: StatesService) { + } + + ngOnInit() { + this.getStates(); + } + + private getStates() { + this.statesService.list(this.deviceId, this.tenantId).subscribe((states) => { + this.states = states.deviceStates; + }, (error) => { + console.log(error); + }) + } +} diff --git a/device-management-ui/src/app/components/devices/device-list/device-list.component.html b/device-management-ui/src/app/components/devices/device-list/device-list.component.html new file mode 100644 index 00000000..081f885f --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-list/device-list.component.html @@ -0,0 +1,112 @@ +
+ + +
+
+ Tenant has no devices yet. Please create a new device. +
+
+ +
+ + + + + + + + + + + + + + + +
+ + + + + Actions +
+ + + + + +
+
+ +
+ + + + + + + + + + + + + +
+ + + +
+ + + + +
+
+
+ + diff --git a/device-management-ui/src/app/components/devices/device-list/device-list.component.scss b/device-management-ui/src/app/components/devices/device-list/device-list.component.scss new file mode 100644 index 00000000..0c39ded0 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-list/device-list.component.scss @@ -0,0 +1,35 @@ +.list-head, .list-item { + .ml-auto { + margin-left: auto!important; + } +} + +.list { + .list-head { + font-weight: bold; + font-size: 12px; + } + + .list-item { + border-top: 1px solid #ccc; + font-size: 14px; + .selectDevice { + cursor: pointer; + a { + text-decoration: underline; + } + } + } +} + + +.card-table-view { + max-height: calc(100vh - 530px); + overflow-y: auto; +} + +.checkmark { + border-radius: 3px; + height: 20px; + width: 20px; +} diff --git a/device-management-ui/src/app/components/devices/device-list/device-list.component.spec.ts b/device-management-ui/src/app/components/devices/device-list/device-list.component.spec.ts new file mode 100644 index 00000000..b433f2c4 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-list/device-list.component.spec.ts @@ -0,0 +1,81 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {DeviceListComponent} from './device-list.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {Tenant} from "../../../models/tenant"; +import {Device} from "../../../models/device"; +import {Router} from "@angular/router"; +import {RouterTestingModule} from "@angular/router/testing"; + +describe('DeviceListComponent', () => { + let component: DeviceListComponent; + let fixture: ComponentFixture; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, NgbModule, RouterTestingModule], + declarations: [DeviceListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return creationTime', () => { + const status = {created: '12345'}; + + const result = component['getCreationTime'](status); + expect(result).toEqual('12345'); + }); + + it('should return "-"', () => { + const status = {name: '12345'}; + + const result = component['getCreationTime'](status); + expect(result).toEqual('-'); + }); + + it('should navigate to device detail on selectDevice', () => { + const device = new Device(); + device.id = 'device-id'; + const tenant = new Tenant(); + tenant.id = 'tenant-id'; + + component.tenant = tenant; + + component['selectDevice'](device); + + expect(router.navigate).toHaveBeenCalledWith( + ['device-detail', 'device-id'], + {state: {tenant, device}} + ); + }); + +}); diff --git a/device-management-ui/src/app/components/devices/device-list/device-list.component.ts b/device-management-ui/src/app/components/devices/device-list/device-list.component.ts new file mode 100644 index 00000000..33a9e1f2 --- /dev/null +++ b/device-management-ui/src/app/components/devices/device-list/device-list.component.ts @@ -0,0 +1,264 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + QueryList, + ViewChildren +} from '@angular/core'; +import {Router} from '@angular/router'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {DeleteComponent} from '../../modals/delete/delete.component'; +import {Tenant} from "../../../models/tenant"; +import {Device} from "../../../models/device"; +import {DeviceService} from "../../../services/device/device.service"; +import {SortableTableDirective, SortEvent} from "../../../services/sortable-table/sortable-table.directive"; +import {SortableTableService} from "../../../services/sortable-table/sortable-table.service"; +import {NotificationService} from "../../../services/notification/notification.service"; +import {CreateAndBindModalComponent} from "../../modals/create-and-bind-modal/create-and-bind-modal.component"; + +@Component({ + selector: 'app-device-list', + templateUrl: './device-list.component.html', + styleUrls: ['./device-list.component.scss'] +}) +export class DeviceListComponent implements OnInit{ + + @ViewChildren(SortableTableDirective) + public sortableHeaders: QueryList = new QueryList(); + + @Input() public tenant: Tenant = new Tenant(); + @Input() public devices: Device[] = []; + @Input() public deviceId: string = ''; + @Input() public isGateway!: boolean; + @Input() public deviceListCount: number = 0; + @Input() public boundDevicesToGateway: boolean = false; + @Input() public unbindDevices: boolean = false; + + @Output() public pageSizeChanged: EventEmitter = new EventEmitter(); + @Output() public selectedDevicesChanged: EventEmitter = new EventEmitter(); + + public deviceIdLabel: string = 'Device ID'; + public deviceCreatedLabel: string = 'Created (UTC)' + public searchLabel = ''; + public searchTerm!: string; + private pageSize: number = 50; + private selectedDevices: Device[] = []; + private pageOffset: number = 0; + private exactSearchString: string = 'Search exact Device ID'; + private searchString = 'Search'; + + constructor(private router: Router, + private modalService: NgbModal, + private deviceService: DeviceService, + private sortableTableService: SortableTableService, + private notificationService: NotificationService) { + } + + ngOnInit() { + this.searchLabel = this.boundDevicesToGateway ? this.searchString : this.exactSearchString; + this.listDevices(); + } + + public onSort({ column, direction }: SortEvent) { + this.sortableHeaders = this.sortableTableService.resetHeaders(this.sortableHeaders, column); + this.devices = this.sortableTableService.sortItems(this.devices, {column, direction}); + } + + public getCreationTime(status: any) { + if (status && status.created) { + return status.created; + } + return '-'; + } + + public selectDevice(device: Device): void { + this.router.navigate(['device-detail/' + this.tenant.id, device.id], { + state: { + tenant: this.tenant, + device: device + } + }); + } + + public createDevice(): void { + const modalRef = this.modalService.open(CreateAndBindModalComponent, {size: 'lg'}); + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.componentInstance.isDeviceFlag = true; + modalRef.result.then((device) => { + if (device) { + this.listDevices(); + this.notificationService.success("Successfully created device " + device.id.toBold()); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public deleteDevice(device: Device): void { + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Confirm Delete'; + modalRef.componentInstance.body = 'Do you really want to delete the device ' + device.id.toBold() + '?'; + modalRef.result.then((res) => { + if (res) { + this.delete(device); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public deviceListIsEmpty(): boolean { + return !this.devices || this.devices.length === 0; + } + + public unbindDevicesFromGateway(){ + this.unbindDevices = true; + + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Confirm Unbind'; + modalRef.componentInstance.body = 'Do you really want to unbind the selected devices?'; + modalRef.componentInstance.unbind = true; + + modalRef.result.then((res) => { + if(res) { + this.unbind(); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public bindNewDevicesToGateway(){ + const modalRef = this.modalService.open(CreateAndBindModalComponent, {size: 'lg'}); + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.componentInstance.deviceId = this.deviceId; + modalRef.componentInstance.isBindDeviceFlag = true; + modalRef.componentInstance.boundDevicesCount = this.deviceListCount; + modalRef.componentInstance.isGateway = this.isGateway; + + modalRef.componentInstance.devicesSelected.subscribe((selectedDevices: Device[]) => { + this.devices.push(...selectedDevices); + }); + } + + + public navigateBack() { + this.router.navigate(['tenant-detail', this.tenant.id], { + state: { + tenant: this.tenant + } + }); + } + + public devicesSelectedCheck(): boolean{ + return this.devices.some(device => device.checked); + } + + public markDevice(selectedDevice: Device) { + selectedDevice.checked = !selectedDevice.checked; + this.selectedDevices = this.devices.filter(device => device.checked); + this.selectedDevicesChanged.emit(this.selectedDevices); + } + + public searchForDevice() { + if (this.boundDevicesToGateway) { + return; + } + if (!this.searchTerm) { + this.listDevices() + } else { + this.deviceService.getByExactId(this.tenant.id, this.searchTerm).subscribe({ + next: (result) => { + result.id = this.searchTerm + this.devices = [result]; + this.deviceListCount = 1; + }, + error: (_err) => { + this.notificationService.error('There is no device or gateway with such an ID. The search only finds exact matches.') + } + }) + } + } + + public changePage($event: number) { + this.pageOffset = ($event -1) * this.pageSize; + this.listDevices(); + } + + public changePageSize(size: number) { + this.pageSize = size; + this.pageOffset = 0; + this.listDevices(); + } + + private listDevices() { + if (this.boundDevicesToGateway) { + return; + } + this.deviceService.listByTenant(this.tenant.id, this.pageSize, this.pageOffset, false).subscribe((listResult) => { + this.devices = listResult.result; + this.deviceListCount = listResult.total; + }, (error) => { + console.log(error); + }); + } + + private unbind() { + for (const selectedDevice of this.selectedDevices) { + const index= selectedDevice.via?.indexOf(this.deviceId); + if (index !== -1) { + selectedDevice.via?.splice(index as number, 1); + for (let i = 0; i < this.devices.length; i++) { + if (selectedDevice === this.devices[i]) { + this.devices.splice(i, 1); + break; + } + } + this.deviceService.update(selectedDevice, this.tenant.id).subscribe( + () => { + this.deviceListCount = this.devices.length + if (this.devices.length <= 0) { + this.navigateBack(); + } + }, + (error) => { + console.log('Error updating device after unbinding: ', selectedDevice, error); + this.notificationService.error('Could not update device after unbinding'); + } + ); + } + } + } + + private delete(device: Device) { + this.deviceService.delete(device, this.tenant.id).subscribe(() => { + const index = this.devices.indexOf(device); + if (index >= 0) { + this.devices.splice(index, 1); + this.deviceListCount = this.deviceListCount -1; + this.notificationService.success("Successfully deleted device " + device.id.toBold()); + } + }, (error) => { + console.log(error); + this.notificationService.error("Could not delete device " + device.id.toBold()); + }) + } + +} diff --git a/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.html b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.html new file mode 100644 index 00000000..26d3623b --- /dev/null +++ b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.html @@ -0,0 +1,75 @@ + + +
+
+ Tenant has no gateways yet to display. +
+
+ +
+ + + + + + + + + + + + + + + +
+ Gateway ID + + Created (UTC) + + Actions +
+ + + + + +
+
+ + diff --git a/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.scss b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.spec.ts b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.spec.ts new file mode 100644 index 00000000..535244fd --- /dev/null +++ b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.spec.ts @@ -0,0 +1,80 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {GatewayListComponent} from './gateway-list.component'; +import {HttpClientModule} from "@angular/common/http"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {NgbModule, NgbPagination} from "@ng-bootstrap/ng-bootstrap"; +import {Device} from "../../../models/device"; +import {Tenant} from "../../../models/tenant"; +import {Router} from "@angular/router"; +import {RouterTestingModule} from "@angular/router/testing"; + +describe('GatewayListComponent', () => { + let component: GatewayListComponent; + let fixture: ComponentFixture; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GatewayListComponent], + imports: [HttpClientModule, OAuthModule.forRoot(), FontAwesomeTestingModule, NgbPagination, NgbModule, RouterTestingModule], + }) + .compileComponents(); + + fixture = TestBed.createComponent(GatewayListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return creationTime in gateway list', () => { + const status = {created: '12345'}; + + const result = component['getCreationTime'](status); + expect(result).toEqual('12345'); + }); + + it('should return "-" in gateway list', () => { + const status = {name: '12345'}; + + const result = component['getCreationTime'](status); + expect(result).toEqual('-'); + }); + + it('should navigate to gateway detail on selectGateway', () => { + const device = new Device(); + device.id = 'gateway-id'; + const tenant = new Tenant(); + tenant.id = 'tenant-id'; + const isGateway = true; + + component.tenant = tenant; + + component['selectGateway'](device); + + expect(router.navigate).toHaveBeenCalledWith( + ['device-detail', 'gateway-id'], + {state: {tenant, device, isGateway}} + ); + }); +}); diff --git a/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.ts b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.ts new file mode 100644 index 00000000..a5f29db7 --- /dev/null +++ b/device-management-ui/src/app/components/gateways/gateway-list/gateway-list.component.ts @@ -0,0 +1,177 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input, QueryList, ViewChildren} from '@angular/core'; +import {SortableTableDirective, SortEvent} from "../../../services/sortable-table/sortable-table.directive"; +import {Tenant} from "../../../models/tenant"; +import {Device} from "../../../models/device"; +import {Router} from "@angular/router"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {DeviceService} from "../../../services/device/device.service"; +import {SortableTableService} from "../../../services/sortable-table/sortable-table.service"; +import {NotificationService} from "../../../services/notification/notification.service"; +import {DeleteComponent} from "../../modals/delete/delete.component"; +import {CreateAndBindModalComponent} from "../../modals/create-and-bind-modal/create-and-bind-modal.component"; + +@Component({ + selector: 'app-gateway-list', + templateUrl: './gateway-list.component.html', + styleUrls: ['./gateway-list.component.scss'] +}) +export class GatewayListComponent { + + @ViewChildren(SortableTableDirective) + public sortableHeaders: QueryList = new QueryList(); + + @Input() public tenant: Tenant = new Tenant(); + + public gatewayListCount: number = 0; + public searchTerm!: string; + public pageSize: number = 50; + public gateways: Device[] = []; + private pageOffset: number = 0; + + constructor(private router: Router, + private modalService: NgbModal, + private deviceService: DeviceService, + private sortableTableService: SortableTableService, + private notificationService: NotificationService) { + } + + public ngOnInit() { + this.listGateways(); + } + + public searchForGateway() { + if (!this.searchTerm) { + this.listGateways() + } else { + this.deviceService.getByExactId(this.tenant.id, this.searchTerm).subscribe({ + next: (result) => { + result.id = this.searchTerm + this.gateways = [result]; + this.gatewayListCount = 1; + }, + error: (_err) => { + this.notificationService.error('There is no device or gateway with such an ID. The search only finds exact matches.') + } + }) + } + } + + public changePage($event: number) { + this.pageOffset = ($event - 1) * this.pageSize; + this.listGateways(); + } + + public changePageSize(size: number) { + this.pageSize = size; + this.pageOffset = 0; + this.listGateways(); + } + + public onSort({column, direction}: SortEvent) { + this.sortableHeaders = this.sortableTableService.resetHeaders(this.sortableHeaders, column); + this.gateways = this.sortableTableService.sortItems(this.gateways, {column, direction}); + } + + public getCreationTime(status: any) { + if (status && status['created']) { + return status['created']; + } + return '-'; + } + + public selectGateway(gateway: Device): void { + this.router.navigate(['device-detail/' + this.tenant.id, gateway.id], { + state: { + tenant: this.tenant, + device: gateway, + isGateway: true, + }, + }); + } + + public createGateway(): void { + const modalRef = this.modalService.open(CreateAndBindModalComponent, {size: 'lg'}); + modalRef.componentInstance.tenantId = this.tenant.id; + modalRef.componentInstance.isGatewayFlag = true; + modalRef.result.then((gateway) => { + if (gateway) { + this.gateways = [...this.gateways,gateway] + this.notificationService.success("Successfully created gateway " + gateway.id.toBold()); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public deleteGateway(gateway: Device): void { + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Confirm Delete'; + modalRef.componentInstance.body = 'Do you really want to delete the gateway ' + gateway.id.toBold() + '?'; + modalRef.result.then((res) => { + if (res) { + this.delete(gateway); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public gatewayListIsEmpty(): boolean { + return !this.gateways || this.gateways.length === 0; + } + + private listGateways() { + this.deviceService.listByTenant(this.tenant.id, this.pageSize, this.pageOffset, true).subscribe((listResult) => { + this.gateways = listResult.result; + this.gatewayListCount = listResult.total; + }, (error) => { + console.log(error); + }); + } + + private delete(gateway: Device) { + this.deviceService.delete(gateway, this.tenant.id).subscribe(() => { + + this.deviceService.listBoundDevices(this.tenant.id, gateway.id, this.pageSize, this.pageOffset).subscribe((deviceList) => { + const boundDevices = deviceList.result; + boundDevices.forEach((boundDevice: Device) => { + if (boundDevice.via != null) { + const index = boundDevice.via.indexOf(gateway.id) + if (index >= 0) { + boundDevice.via.splice(index, 1); + + this.deviceService.update(boundDevice, this.tenant.id).subscribe((result) => { + console.log('update result: ', result); + }); + } + } + }); + }); + + const index = this.gateways.indexOf(gateway); + if (index >= 0) { + this.gateways.splice(index, 1); + this.gatewayListCount = this.gatewayListCount - 1; + this.notificationService.success("Successfully deleted gateway " + gateway.id.toBold()); + } + }, (error) => { + console.log(error); + this.notificationService.error("Could not delete gateway " + gateway.id.toBold()); + }) + } +} diff --git a/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.scss b/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.spec.ts b/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.spec.ts new file mode 100644 index 00000000..dcbc4f4f --- /dev/null +++ b/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.spec.ts @@ -0,0 +1,38 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoaderSpinnerComponent } from './loader-spinner.component'; + +describe('LoaderComponent', () => { + let component: LoaderSpinnerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ LoaderSpinnerComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoaderSpinnerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.ts b/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.ts new file mode 100644 index 00000000..cdbc784f --- /dev/null +++ b/device-management-ui/src/app/components/loading-spinner/loader-spinner.component.ts @@ -0,0 +1,41 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, OnInit} from '@angular/core'; +import {Subject} from 'rxjs'; +import {LoadingSpinnerService} from '../../services/loading-spinner/loading-spinner.service'; + +@Component({ + selector: 'loading-spinner', + templateUrl: './loading-spinner.component.html', + styleUrls: ['./loader-spinner.component.scss'] +}) +export class LoaderSpinnerComponent implements OnInit { + + public isLoading: boolean = false; + private loaderSubject: Subject = this.loaderService.isLoading; + + constructor(private loaderService: LoadingSpinnerService) { + } + + public ngOnInit() { + this.loaderSubject.subscribe((isLoading) => { + setTimeout(() => { + this.isLoading = isLoading; + }); + }) + } + +} diff --git a/device-management-ui/src/app/components/loading-spinner/loading-spinner.component.html b/device-management-ui/src/app/components/loading-spinner/loading-spinner.component.html new file mode 100644 index 00000000..31e56080 --- /dev/null +++ b/device-management-ui/src/app/components/loading-spinner/loading-spinner.component.html @@ -0,0 +1,5 @@ +
+
+ Loading... +
+
diff --git a/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.html b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.html new file mode 100644 index 00000000..f73bf73b --- /dev/null +++ b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.html @@ -0,0 +1,64 @@ + + diff --git a/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.scss b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.spec.ts b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.spec.ts new file mode 100644 index 00000000..83671617 --- /dev/null +++ b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.spec.ts @@ -0,0 +1,233 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CreateAndBindModalComponent } from './create-and-bind-modal.component'; +import {NgbActiveModal, NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {ModalHeadComponent} from "../modal-head/modal-head.component"; +import {ModalFooterComponent} from "../modal-footer/modal-footer.component"; +import {SelectDevicesComponent} from "../select-devices/select-devices.component"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {Device} from "../../../models/device"; +import {of} from "rxjs"; +import {DeviceService} from "../../../services/device/device.service"; +import {FormsModule} from "@angular/forms"; + +describe('CreateAndBindModalComponent', () => { + let component: CreateAndBindModalComponent; + let fixture: ComponentFixture; + let activeModalSpy: jasmine.SpyObj; + let deviceServiceSpy: { + save: jasmine.Spy, + listByTenant: jasmine.Spy + }; + + beforeEach(async () => { + activeModalSpy = jasmine.createSpyObj('NgbActiveModal', ['close']); + + await TestBed.configureTestingModule({ + imports: [NgbModule, HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, FormsModule], + declarations: [ CreateAndBindModalComponent, ModalHeadComponent, ModalFooterComponent, SelectDevicesComponent], + providers: [ + {provide: NgbActiveModal, useValue: activeModalSpy} + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + deviceServiceSpy = jasmine.createSpyObj('DeviceService', ['create', 'listByTenant']); + fixture = TestBed.createComponent(CreateAndBindModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close the active modal on cancel', () => { + component['onClose'](); + expect(activeModalSpy.close).toHaveBeenCalled(); + }); + + it('should return false when isDeviceFlag and all required properties are set ', () => { + const device = new Device(); + component.isDeviceFlag = true; + device.id = 'test-device-id'; + + component.device = device; + component.tenantId = 'test-tenant-id' + component.sendViaGateway = false; + + expect(component.isInvalid()).toBeFalse(); + }); + + it('should return true when isDeviceFlag and required properties are not set ', () => { + const device = new Device(); + component.isDeviceFlag = true; + device.id = 'test-device-id'; + + component.device = device; + component.sendViaGateway = false; + + expect(component.isInvalid()).toBeTruthy(); + }); + + it('should call createDevice when isDeviceFlag is true', () => { + component.isDeviceFlag = true; + + const createDeviceSpy = spyOn(component as any, 'createDevice').and.callThrough(); + const createGatewaySpy = spyOn(component as any, 'createGateway').and.callThrough(); + const bindDeviceSpy = spyOn(component as any, 'bindDevice').and.callThrough(); + spyOn(component, 'isInvalid').and.returnValue(false) + + component.onConfirm(); + + expect(createDeviceSpy).toHaveBeenCalled(); + expect(createGatewaySpy).not.toHaveBeenCalled(); + expect(bindDeviceSpy).not.toHaveBeenCalled(); + }); + + it('should call createGateway when isGatewayFlag is true', () => { + component.isGatewayFlag = true; + + const createDeviceSpy = spyOn(component as any, 'createDevice').and.callThrough(); + const createGatewaySpy = spyOn(component as any, 'createGateway').and.callThrough(); + const bindDeviceSpy = spyOn(component as any, 'bindDevice').and.callThrough(); + spyOn(component, 'isInvalid').and.returnValue(false) + + component.onConfirm(); + + expect(createDeviceSpy).not.toHaveBeenCalled(); + expect(createGatewaySpy).toHaveBeenCalled(); + expect(bindDeviceSpy).not.toHaveBeenCalled(); + }); + + it('should call bindDevice when isBindDeviceFlag is true', () => { + component.isBindDeviceFlag = true; + + const createDeviceSpy = spyOn(component as any, 'createDevice').and.callThrough(); + const createGatewaySpy = spyOn(component as any, 'createGateway').and.callThrough(); + const bindDeviceSpy = spyOn(component as any, 'bindDevice').and.callThrough(); + spyOn(component, 'isInvalid').and.returnValue(false) + + component.onConfirm(); + + expect(createDeviceSpy).not.toHaveBeenCalled(); + expect(createGatewaySpy).not.toHaveBeenCalled(); + expect(bindDeviceSpy).toHaveBeenCalled(); + }); + + it('should create a device when onConfirm is called and should close modal after save', () => { + const device = new Device(); + device.id = 'test-device-id'; + + const gateway = new Device(); + gateway.id = 'test-gateway-id'; + + component.selectedDevices = [gateway]; + component.device = device; + component.tenantId = 'test-tenant-id'; + component.isDeviceFlag = true; + component.sendViaGateway = true; + + const saveSpy = spyOn(component['deviceService'], 'create').and.returnValue(of(true)); + + component.onConfirm(); + expect(component.device.via).toHaveSize(1); + expect(saveSpy).toHaveBeenCalledWith(component.device, component.tenantId); + expect(activeModalSpy.close).toHaveBeenCalledWith(component.device); + }); + + it('should create a gateway when onConfirm is called and should close modal after save', () => { + const gateway = new Device(); + gateway.id = 'test-device-id'; + + component.device = gateway; + component.tenantId = 'test-tenant-id'; + component.isGatewayFlag = true; + component.selectedDevices = [new Device()]; + + const saveSpy = spyOn(component['deviceService'], 'create').and.returnValue(of(true)); + const updateSpy = spyOn(component["deviceService"], 'update').and.returnValue(of(component.selectedDevices)); + + component.onConfirm(); + expect(saveSpy).toHaveBeenCalledWith(component.device, component.tenantId); + expect(updateSpy).toHaveBeenCalledWith(component.selectedDevices[0], component.tenantId); + expect(activeModalSpy.close).toHaveBeenCalledWith(component.device); + }); + + it('should bind new device(s) when onConfirm is called and should close modal after save', () => { + const device1 = new Device(); + device1.id = 'device1'; + + const device2 = new Device(); + device2.id = 'device2'; + + const gateway = new Device(); + gateway.id = 'gateway1'; + + component.device = gateway; + component.tenantId = 'test-tenant-id'; + component.selectedDevices = [device1, device2]; + component.isBindDeviceFlag = true; + + const updateSpy = spyOn(component["deviceService"], 'update').and.returnValue(of()); + + component.onConfirm(); + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(updateSpy).toHaveBeenCalledWith(device1, 'test-tenant-id'); + expect(updateSpy).toHaveBeenCalledWith(device2, 'test-tenant-id'); + expect(activeModalSpy.close).toHaveBeenCalledWith(component.device); + }); + + it('should do nothing when device is invalid', () => { + const device = new Device(); + device.id = 'test-device-id'; + + component.device = device; + component.tenantId = 'test-tenant-id'; + component.sendViaGateway = true; + component.isDeviceFlag = true; + + const saveSpy = spyOn(component['deviceService'], 'create').and.callThrough(); + + component.onConfirm(); + expect(saveSpy).not.toHaveBeenCalled(); + expect(activeModalSpy.close).not.toHaveBeenCalled(); + }); + + it('should set pageOffset and call listAll function', () => { + component['pageOffset'] = 2; + component['pageSize'] = 10; + component.tenantId = 'test-tenant-id'; + + const listResult = { + result: [new Device(), new Device()], + total: 12 + }; + const listSpy = spyOn(component['deviceService'], 'listByTenant').and.returnValue(of(listResult)); + + component['onPageOffsetChanged'](3); + + expect(listSpy).toHaveBeenCalledWith('test-tenant-id', 10, 3, false); + expect(component['pageOffset']).toEqual(3); + expect(component.devices.length).toEqual(2); + }); +}); diff --git a/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.ts b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.ts new file mode 100644 index 00000000..ee84e203 --- /dev/null +++ b/device-management-ui/src/app/components/modals/create-and-bind-modal/create-and-bind-modal.component.ts @@ -0,0 +1,215 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {Device} from "../../../models/device"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {DeviceService} from "../../../services/device/device.service"; +import {NotificationService} from "../../../services/notification/notification.service"; + +@Component({ + selector: 'app-create-and-bind-modal', + templateUrl: './create-and-bind-modal.component.html', + styleUrls: ['./create-and-bind-modal.component.scss'] +}) +export class CreateAndBindModalComponent implements OnInit{ + + @Input() public device: Device = new Device(); + @Input() public tenantId: string = ''; + @Input() public isDeviceFlag: boolean = false; + @Input() public isGatewayFlag: boolean = false; + @Input() public deviceId: string = ''; + @Input() public isGateway: boolean = false; + @Input() public isBindDeviceFlag: boolean = false; + @Input() public boundDevicesCount: number = 0; + + @Output() public devicesSelected: EventEmitter = new EventEmitter(); + + public sendViaGateway: boolean = false; + public selectedDevices: Device[] = []; + public devices: Device[] = []; + public modalTitle: string = ''; + public deviceListCount: number = 0; + public pageSize: number = 50; + public selectLabel: string = ''; + public gatewayTooltip: string = + 'Select one or more devices to bind.
'; + private pageOffset: number = 0; + + constructor(private activeModal: NgbActiveModal, + private deviceService: DeviceService, + private notificationService: NotificationService) { + } + + public ngOnInit(): void { + this.setModalTitle(); + this.listDevices(); + } + + public onClose() { + this.activeModal.close(); + } + + public onConfirm() { + if (this.isInvalid()) { + return; + } + + if (this.isDeviceFlag) { + this.createDevice(); + } else if (this.isGatewayFlag) { + this.createGateway(); + } else if (this.isBindDeviceFlag) { + this.bindDevice(); + } + } + + public onPageOffsetChanged($event: number) { + this.pageOffset = $event; + this.listDevices(); + } + + public onSelectedDevicesChanged($event: Device[]) { + this.selectedDevices = $event; + for (const selectedDevice of this.selectedDevices) { + this.device.via?.push(selectedDevice.id); + } + } + + public isInvalid(): boolean { + const checkDeviceAndTenant = !this.device?.id || !this.tenantId; + const checkSendViaGateway = this.sendViaGateway && (!this.selectedDevices || this.selectedDevices.length === 0); + if(this.isDeviceFlag) { + return checkDeviceAndTenant || checkSendViaGateway; + } else if(this.isGatewayFlag) { + return checkDeviceAndTenant || !this.selectedDevices || this.selectedDevices.length === 0; + } else { + return !this.tenantId || checkSendViaGateway; + } + } + + private listDevices() { + let onlyGateways = false; + + if(this.isDeviceFlag) { + this.selectLabel = 'Select gateway(s).'; + onlyGateways = true; + } else if (this.isGatewayFlag || this.isBindDeviceFlag) { + this.selectLabel = 'Select device(s).'; + this.sendViaGateway = true; + } + + this.deviceService.listByTenant(this.tenantId, this.pageSize, this.pageOffset, onlyGateways).subscribe((listResult) => { + if(this.isBindDeviceFlag) { + this.listAvailableBindingDevices(listResult); + } else { + this.devices = listResult.result; + this.deviceListCount = listResult.total; + } + }, (error) => { + console.log(error); + }); + + } + + private listAvailableBindingDevices(listResult: any) { + if (this.isGateway) { + this.deviceListCount = listResult.total - this.boundDevicesCount; + this.devices = listResult.result.filter((element: Device) => { + return !element.via?.includes(this.deviceId); + }); + } + else { + this.deviceListCount = listResult.total - 1; + const index = listResult.result.findIndex((object: Device) => { + return object.id === this.deviceId; + }); + this.devices = listResult.result; + if (index !== -1) { + this.devices.splice(index, 1); + } + } + } + + private createDevice() { + this.device.via = []; + for (const dev of this.selectedDevices) { + this.device.via?.push(dev.id) + } + this.deviceService.create(this.device, this.tenantId).subscribe((result) => { + if (result) { + this.activeModal.close(this.device); + } + }, (error) => { + console.log('Error saving device', this.device.id, error); + this.notificationService.error('Could not create device for id ' + this.device.id.toBold()); + }); + } + + private createGateway() { + this.deviceService.create(this.device, this.tenantId).subscribe((result) => { + if (result) { + for (const selectedDevice of this.selectedDevices) { + if (!selectedDevice.via) { + selectedDevice.via = []; + } + if (!selectedDevice.via.includes(this.device.id)) { + selectedDevice.via.push(this.device.id); + this.deviceService.update(selectedDevice, this.tenantId).subscribe(() => { + }, (error) => { + console.log('Error saving gateway', this.device.id, error); + this.notificationService.error('Could not create gateway for id ' + this.device.id.toBold()); + }); + } + } + this.activeModal.close(this.device); + } + }, (error) => { + console.log('Error saving gateway', this.device.id, error); + this.notificationService.error('Could not create gateway for id ' + this.device.id.toBold()); + }); + } + + private bindDevice() { + for (const selectedDevice of this.selectedDevices) { + if (!selectedDevice.via) { + selectedDevice.via = []; + } + if (!selectedDevice.via.includes(this.deviceId)) { + selectedDevice.via.push(this.deviceId); + this.deviceService.update(selectedDevice, this.tenantId).subscribe((result) => { + console.log('update result: ', result); + }, (error) => { + console.log('Error binding device', this.deviceId, error); + this.notificationService.error('Could not bind device to gateway ' + this.deviceId.toBold()); + }); + } + selectedDevice.checked = false; + } + const selectedDevices: Device [] = this.selectedDevices; + this.devicesSelected.emit(selectedDevices); + this.activeModal.close(this.device); + } + + private setModalTitle() { + if (this.isDeviceFlag) { + this.modalTitle = 'Create new device'; + } else if (this.isGatewayFlag) { + this.modalTitle = 'Create new gateway'; + } else if (this.isBindDeviceFlag) { + this.modalTitle = 'Bind device(s)'; + } + } +} diff --git a/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.html b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.html new file mode 100644 index 00000000..7c8743fe --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.html @@ -0,0 +1,56 @@ + diff --git a/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.scss b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.spec.ts b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.spec.ts new file mode 100644 index 00000000..a3bd4987 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.spec.ts @@ -0,0 +1,192 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {CredentialsModalComponent} from './credentials-modal.component'; +import {NgbActiveModal, NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {ModalHeadComponent} from "../modal-head/modal-head.component"; +import {NgSelectModule} from "@ng-select/ng-select"; +import {ModalFooterComponent} from "../modal-footer/modal-footer.component"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {FormsModule} from "@angular/forms"; +import {Credentials, CredentialTypes} from "../../../models/credentials/credentials"; +import {Secret} from "../../../models/credentials/secret"; +import {CredentialsService} from "../../../services/credentials/credentials.service"; +import {of, throwError} from "rxjs"; +import {DeviceRpkModalComponent} from "./device-rpk-modal/device-rpk-modal.component"; + +describe('CredentialsModalComponent', () => { + let component: CredentialsModalComponent; + let fixture: ComponentFixture; + let activeModalSpy: jasmine.SpyObj; + let credentialsServiceSpy: CredentialsService; + let deviceRpkModalComponentSpy: jasmine.SpyObj; + + beforeEach(async () => { + activeModalSpy = jasmine.createSpyObj('NgbActiveModal', ['close']); + deviceRpkModalComponentSpy = jasmine.createSpyObj('DeviceRpkModalComponent', ['setNotBeforeDateTime']); + await TestBed.configureTestingModule({ + imports: [NgbModule, HttpClientTestingModule, OAuthModule.forRoot(), NgSelectModule, FontAwesomeTestingModule, FormsModule], + declarations: [CredentialsModalComponent, ModalHeadComponent, ModalFooterComponent], + providers: [ + {provide: NgbActiveModal, useValue: activeModalSpy}, + {provide: CredentialsService}, + {provide: DeviceRpkModalComponent, useValue: deviceRpkModalComponentSpy} + ] + }) + .compileComponents(); + + credentialsServiceSpy = TestBed.inject(CredentialsService); + fixture = TestBed.createComponent(CredentialsModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set modalTitle to "Add Credentials" when isNewCredentials is true', () => { + component.isNewCredentials = true; + + component.ngOnInit(); + expect(component['modalTitle']).toEqual('Add Credentials'); + }); + + it('should set modalTitle to "Update Credentials" and authId and authType when isNewCredentials is false and credential is provided', () => { + component.isNewCredentials = false; + component.credential = { + 'auth-id': 'auth123', + type: 'hashed-password', + secrets: [] + }; + + component.ngOnInit(); + expect(component['modalTitle']).toEqual('Update Credentials'); + expect(component['authId']).toEqual('auth123'); + expect(component['authType']).toEqual('hashed-password'); + }); + + it('should return true when deviceId is not set', () => { + component.tenantId = 'testTenantId'; + expect(component['isInvalid']()).toBeTrue(); + }); + + it('should return true when tenantId is not set', () => { + component.deviceId = 'testDeviceId'; + expect(component['isInvalid']()).toBeTrue(); + }); + + it('should return true when authentication is not valid', () => { + component.deviceId = 'testDeviceId'; + component.tenantId = 'testTenantId'; + component.credential = new Credentials(); + component.credential.secrets = [new Secret()]; + component['authId'] = ''; + component['authType'] = ''; + + expect(component['isInvalid']()).toBeTrue(); + }); + + it('should return true when secret is not available', () => { + component.deviceId = 'testDeviceId'; + component.tenantId = 'testTenantId'; + component.credential = new Credentials(); + component.credential.secrets = []; + component['authId'] = 'testAuthId'; + component['authType'] = 'testAuthType'; + + expect(component['isInvalid']()).toBeTrue(); + }); + + it('should return false when values are valid', () => { + component.deviceId = 'testDeviceId'; + component.tenantId = 'testTenantId'; + component.credential = new Credentials(); + component.credential.secrets = [new Secret()]; + component['authId'] = 'testAuthId'; + component['authType'] = 'testAuthType'; + + expect(component['isInvalid']()).toBeFalse(); + }); + + it('should close the modal', () => { + component['onClose'](); + expect(activeModalSpy.close).toHaveBeenCalled(); + }); + + it('should call the credentials service save method when credentials are valid', () => { + spyOn(credentialsServiceSpy, 'save').and.returnValue(of({})); + + const secret: Secret = new Secret(); + secret.enabled = true; + secret.algorithm = 'RSA'; + secret.key = 'your-public-key-value'; + secret['not-before'] = '2023-01-01'; + secret['not-after'] = '2024-01-01'; + + component.deviceId = 'test-device-id'; + component.tenantId = 'test-tenant-id'; + component.credential.secrets = [secret]; + component['authId'] = 'test-auth-id'; + component['authType'] = CredentialTypes.HASHED_PASSWORD; + + component['onConfirm'](); + expect(credentialsServiceSpy.save).toHaveBeenCalledWith( + component.deviceId, + component.tenantId, + component.credentials + ); + }); + + it('should add a new credentials', () => { + spyOn(credentialsServiceSpy, 'save').and.returnValue(of({})); + + component.deviceId = 'test-device-id'; + component.tenantId = 'test-tenant-id'; + component.credential.secrets = [new Secret()]; + component['authId'] = 'test-auth-id'; + component['authType'] = CredentialTypes.HASHED_PASSWORD; + + component['onConfirm'](); + expect(component.credentials.length).toEqual(1); + }); + + it('should remove the new credential when there is an error adding it', () => { + spyOn(credentialsServiceSpy, 'save').and.returnValue( + throwError('error adding credentials') + ); + component.deviceId = 'test-device-id'; + component.tenantId = 'test-tenant-id'; + component.credential.secrets = [new Secret()]; + component['authId'] = 'test-auth-id'; + component['authType'] = CredentialTypes.HASHED_PASSWORD; + component.isNewCredentials = true; + + component['onConfirm'](); + expect(component.credentials.length).toEqual(0); + }); + + it('should not call the credentials save method when the credentials are not valid', () => { + spyOn(credentialsServiceSpy, 'save').and.returnValue(of({})); + + component['onConfirm'](); + expect(credentialsServiceSpy.save).not.toHaveBeenCalled(); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.ts b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.ts new file mode 100644 index 00000000..913a01e0 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/credentials-modal.component.ts @@ -0,0 +1,172 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input, OnInit} from '@angular/core'; +import {Credentials, CredentialTypes} from "../../../models/credentials/credentials"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {CredentialsService} from "../../../services/credentials/credentials.service"; +import {NotificationService} from "../../../services/notification/notification.service"; +@Component({ + selector: 'app-credentials-modal', + templateUrl: './credentials-modal.component.html', + styleUrls: ['./credentials-modal.component.scss'] +}) +export class CredentialsModalComponent implements OnInit { + + @Input() public isNewCredentials: boolean = true; + @Input() public deviceId: string = ''; + @Input() public tenantId: string = ''; + @Input() public credential: Credentials = new Credentials(); + @Input() public credentials: Credentials[] = []; + + public isSecretInvalid: boolean = false; + public authTypes: { + key: string, + value: string, + }[] = [ + {key: CredentialTypes.HASHED_PASSWORD, value: 'Password based'}, + {key: CredentialTypes.RPK, value: 'JWT based'}, + ]; + public modalTitle: string = ''; + public authType: string = ''; + public authId: string = ''; + + private publicKeyHeader: string = '-----BEGIN PUBLIC KEY-----'; + private publicKeyFooter: string = '-----END PUBLIC KEY-----'; + private certHeader: string = '-----BEGIN CERTIFICATE-----'; + private certFooter: string = '-----END CERTIFICATE-----'; + private usePublicKey: boolean = true; + + constructor(private activeModal: NgbActiveModal, + private credentialsService: CredentialsService, + private notificationService: NotificationService) { + + } + + public get isPassword() { + return this.authType === CredentialTypes.HASHED_PASSWORD; + } + + public get isRpk() { + return this.authType === CredentialTypes.RPK; + } + + public ngOnInit() { + if (!this.isNewCredentials && this.credential && this.credential["auth-id"] && this.credential.type) { + this.modalTitle = 'Update Credentials'; + this.authId = this.credential["auth-id"]; + this.authType = this.credential.type; + } else { + this.modalTitle = 'Add Credentials'; + } + } + + public setSecret($event: any) { + if ($event === undefined) return; + + this.usePublicKey = $event.usePublicKey; + if (this.usePublicKey == undefined) { + this.isSecretInvalid = this.handlePasswordBasedSecretValidity($event); + } else { + this.isSecretInvalid = this.handleJWTBasedSecretValidity($event); + } + } + + public onClose() { + this.activeModal.close(); + } + + public onConfirm() { + if (!this.deviceId || !this.tenantId) { + return; + } + if (this.isAuthenticationValid()) { + if (this.isNewCredentials) { + this.credential["auth-id"] = this.authId; + this.credential.type = this.authType; + this.credentials.push(this.credential); + } + this.trimKey(this.credential); + this.cleanCert(); + this.credentialsService.save(this.deviceId, this.tenantId, this.credentials).subscribe(() => { + this.activeModal.close(this.credentials); + }, (error) => { + if (this.isNewCredentials) { + const index = this.credentials.indexOf(this.credential); + if (index >= 0) { + this.credentials.splice(index, 1); + } + } + console.log('Error adding credentials for device', this.deviceId, error); + this.notificationService.error("Could not save credentials. Please check your inputs again.") + }); + } + } + + public trimKey(credential: Credentials) { + if (credential.secrets[0].key) { + credential.secrets[0].key = + credential.secrets[0].key?.replaceAll(this.publicKeyHeader,'')?.replaceAll(this.publicKeyFooter,'')?.replaceAll(/\n/g, ''); + } + + if (credential.secrets[0].cert) { + credential.secrets[0].cert = + credential.secrets[0].cert?.replaceAll(this.certHeader,'')?.replaceAll(this.certFooter,'')?.replaceAll(/\n/g, ''); + } + } + + public isInvalid(): boolean { + if (!this.deviceId || !this.tenantId) { + return true; + } + return !this.isAuthenticationValid(); + } + + public onChangeAuthOrPasswordType() { + this.isSecretInvalid = true; + this.credential.secrets = []; + } + + private isAuthenticationValid(): boolean { + return !!this.credential.secrets[0] && !!this.authId && !!this.authType && !this.isSecretInvalid; + } + + private cleanCert() { + if (!this.usePublicKey) { + delete this.credential.secrets[0].algorithm; + delete this.credential.secrets[0].key; + } else { + delete this.credential.secrets[0].cert; + } + } + + private handlePasswordBasedSecretValidity($event: any) { + if ($event["pwd-plain"]?.length === 0 || $event["pwd-hash"]?.length === 0 || $event["hash-function"]?.length === 0) { + return true; + } else { + this.credential.secrets = [$event]; + return false; + } + } + + private handleJWTBasedSecretValidity($event: any) { + if ((this.usePublicKey && $event.secret?.key.length === 0) || (!this.usePublicKey && !$event.secret?.cert) || $event.secret == undefined) { + return true; + } else { + this.credential.secrets = [$event.secret]; + return false; + } + } +} diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.html b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.html new file mode 100644 index 00000000..876b0b74 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.html @@ -0,0 +1,62 @@ +
+
+ + +
+
+
+
+ + * + +
+
+
+
+ + * + +
+ +
+ + +
+ +
+ + * + +
+
+ diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.scss b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.spec.ts b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.spec.ts new file mode 100644 index 00000000..b3528786 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.spec.ts @@ -0,0 +1,105 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {DevicePasswordModalComponent} from './device-password-modal.component'; +import {FormsModule} from "@angular/forms"; +import {Secret} from "../../../../models/credentials/secret"; + +describe('DevicePasswordModalComponent', () => { + let component: DevicePasswordModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsModule], + declarations: [DevicePasswordModalComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DevicePasswordModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set usePlainPassword to true and emit an event', () => { + spyOn(component.passwordSecretChanged, 'emit'); + + component['setUsePlainPassword'](true); + + expect(component['usePlainPassword']).toBeTrue(); + expect(component['passwordSecret']).toEqual(new Secret()); + expect(component.passwordSecretChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should set usePlainPassword to false and emit an event', () => { + spyOn(component.passwordSecretChanged, 'emit'); + + component['setUsePlainPassword'](false); + + expect(component['usePlainPassword']).toBeFalse(); + expect(component['passwordSecret']).toEqual(new Secret()); + expect(component.passwordSecretChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should emit undefined when passwordSecret is invalid', () => { + spyOn(component.passwordSecretChanged, 'emit'); + + component['onPasswordSecretChanged'](); + expect(component.passwordSecretChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should emit passwordSecret when passwordSecret is valid and usePlainPassword is true, should remove pwd-hash, hash-function and salt', () => { + const secret = new Secret(); + secret['pwd-plain'] = 'password'; + secret['pwd-hash'] = 'hash'; + secret['hash-function'] = 'sha256'; + secret['salt'] = 'salt'; + + spyOn(component.passwordSecretChanged, 'emit'); + + component['usePlainPassword'] = true; + component['passwordSecret'] = secret; + + component['onPasswordSecretChanged'](); + + expect(component.passwordSecretChanged.emit).toHaveBeenCalledWith(secret); + expect(component['passwordSecret']['pwd-hash']).toBeUndefined(); + expect(component['passwordSecret']['hash-function']).toBeUndefined(); + expect(component['passwordSecret']['salt']).toBeUndefined(); + }); + + it('should emit passwordSecret when passwordSecret is valid and usePlainPassword is false, should remove pwd-plain', () => { + const secret = new Secret(); + secret['pwd-hash'] = 'hash'; + secret['hash-function'] = 'sha256'; + secret['pwd-plain'] = 'password'; + + spyOn(component.passwordSecretChanged, 'emit'); + + component['usePlainPassword'] = false; + component['passwordSecret'] = secret; + + component['onPasswordSecretChanged'](); + + expect(component.passwordSecretChanged.emit).toHaveBeenCalledWith(secret); + expect(component['passwordSecret']['pwd-plain']).toBeUndefined(); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.ts b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.ts new file mode 100644 index 00000000..aec2496e --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/device-password-modal/device-password-modal.component.ts @@ -0,0 +1,64 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Output} from '@angular/core'; +import {Secret} from "../../../../models/credentials/secret"; + +@Component({ + selector: 'app-device-password-modal', + templateUrl: './device-password-modal.component.html', + styleUrls: ['./device-password-modal.component.scss'] +}) +export class DevicePasswordModalComponent { + + @Output() public passwordSecretChanged: EventEmitter = new EventEmitter(); + @Output() public passwordSecretTypeChanged: EventEmitter = new EventEmitter(); + + public passwordSecret: Secret = new Secret(); + public usePlainPassword: boolean = true; + + public setUsePlainPassword(usePlainPassword: boolean) { + this.usePlainPassword = usePlainPassword; + this.passwordSecret = new Secret(); + this.passwordSecretChanged.emit(undefined); + this.passwordSecretTypeChanged.emit(true); + } + + public onPasswordSecretChanged() { + if (this.isInvalid()) { + this.passwordSecretChanged.emit(undefined); + } else { + delete this.passwordSecret.algorithm; + delete this.passwordSecret.key; + delete this.passwordSecret.cert; + if (this.usePlainPassword) { + delete this.passwordSecret["pwd-hash"]; + delete this.passwordSecret["hash-function"]; + delete this.passwordSecret.salt; + } else { + delete this.passwordSecret["pwd-plain"]; + } + this.passwordSecretChanged.emit(this.passwordSecret); + } + } + + private isInvalid(): boolean { + if (this.usePlainPassword) { + return !this.passwordSecret["pwd-plain"]; + } else { + return !this.passwordSecret["pwd-hash"] || !this.passwordSecret["hash-function"]; + } + } +} diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.html b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.html new file mode 100644 index 00000000..efe24f66 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.html @@ -0,0 +1,104 @@ +
+
+ + +
+
+
+
+ + * + + +
+
+ + * + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + + + +
+
+ + + +
+
+
+ +
+
+
+ + * + +
+ +
+ Please be aware that the X509 Certificate will be converted into a Public Key after it is saved. + So you won´t longer see information about the certificate, but the public key instead. +
+ +
diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.scss b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.spec.ts b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.spec.ts new file mode 100644 index 00000000..b021b5c7 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.spec.ts @@ -0,0 +1,164 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {DeviceRpkModalComponent} from './device-rpk-modal.component'; +import {FormsModule} from "@angular/forms"; +import {NgSelectModule} from "@ng-select/ng-select"; +import {Secret} from "../../../../models/credentials/secret"; +import {NgbModule} from "@ng-bootstrap/ng-bootstrap"; + +describe('DeviceRpkModalComponent', () => { + let component: DeviceRpkModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgSelectModule, FormsModule, NgbModule], + declarations: [DeviceRpkModalComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceRpkModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set rpkSecret to a new Secret if it is not provided', () => { + component.ngOnInit(); + expect(component.rpkSecret).toEqual(new Secret()); + }); + + it('should set notBefore & notAfter to true if rpkSecret has those properties', () => { + component.rpkSecret = { + 'not-before': '2023-06-01T00:00:00.000Z', + 'not-after': '2023-06-30T23:59:59.999Z' + }; + + component.ngOnInit(); + + expect(component['notBefore']).toBeTrue(); + expect(component['notAfter']).toBeTrue(); + }); + + it('should set usePublicKey to true and emit an event when called with true', () => { + spyOn(component.rpkSecretChanged, 'emit'); + + component['setUsePublicKey'](true); + + expect(component['usePublicKey']).toBeTrue(); + expect(component.rpkSecret).toEqual(new Secret()); + expect(component.rpkSecretChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should set usePublicKey to false and emit an event when called with false', () => { + spyOn(component.rpkSecretChanged, 'emit'); + + component['setUsePublicKey'](false); + + expect(component['usePublicKey']).toBeFalse(); + expect(component.rpkSecret).toEqual(new Secret()); + expect(component.rpkSecretChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should emit undefined when rpkSecret is invalid', () => { + spyOn(component.rpkSecretChanged, 'emit'); + + component.rpkSecret = new Secret(); + + component['onRpkSecretChanged'](); + expect(component.rpkSecretChanged.emit).toHaveBeenCalledWith(undefined); + }); + + it('should emit rpkSecret when rpkSecret is valid and should delete cert', () => { + spyOn(component.rpkSecretChanged, 'emit'); + + const secret: Secret = new Secret(); + secret.enabled = true; + secret.algorithm = 'RSA'; + secret.key = 'your-public-key-value'; + secret.cert = 'your-certificate-value'; + secret['not-before'] = '2023-01-01'; + secret['not-after'] = '2024-01-01'; + + component.rpkSecret = secret; + + component['onRpkSecretChanged'](); + + expect(component.rpkSecretChanged.emit).toHaveBeenCalledWith(secret); + expect(component.rpkSecret.cert).toBeUndefined(); + }); + + it('should emit rpkSecret when rpkSecret is valid and should delete algorithm and key', () => { + spyOn(component.rpkSecretChanged, 'emit'); + + const secret: Secret = new Secret(); + secret.enabled = true; + secret.algorithm = 'RSA'; + secret.key = 'your-public-key-value'; + secret.cert = 'your-certificate-value'; + secret['not-before'] = '2023-01-01'; + secret['not-after'] = '2024-01-01'; + + component.rpkSecret = secret; + component['usePublicKey'] = false; + + component['onRpkSecretChanged'](); + + expect(component.rpkSecretChanged.emit).toHaveBeenCalledWith(secret); + expect(component.rpkSecret.cert).toEqual('your-certificate-value'); + expect(component.rpkSecret.key).toBeUndefined(); + expect(component.rpkSecret.algorithm).toBeUndefined(); + }); + + it('should set "not-before" value to undefined when setNotBeforeDateTime is called with invalid event', () => { + component['setNotBeforeDateTime'](null); + + expect(component.rpkSecret['not-before']).toBe(undefined); + }); + + it('should set "not-after" value to undefined when setNotBeforeDateTime is called with invalid event', () => { + component['setNotAfterDateTime'](null); + + expect(component.rpkSecret['not-after']).toBe(undefined); + }); + + it('should return undefined when event data is incomplete ', () => { + const eventMock = { + date: {year: 2023, month: 5, day: 14}, + time: {hour: 12, minute: undefined, second: 0} + }; + + const result = component['setDateTime'](eventMock); + expect(result).toBeUndefined(); + }); + + it('should return the correct ISO string when provided with valid event data', () => { + const eventMock = { + date: {year: 2023, month: 5, day: 14}, + time: {hour: 12, minute: 30, second: 0} + }; + + const expectedISOString = '2023-06-14T10:30:00.000Z'; + const result = component['setDateTime'](eventMock); + expect(result).toEqual(expectedISOString); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.ts b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.ts new file mode 100644 index 00000000..b3c1d447 --- /dev/null +++ b/device-management-ui/src/app/components/modals/credentials-modal/device-rpk-modal/device-rpk-modal.component.ts @@ -0,0 +1,103 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {Secret} from "../../../../models/credentials/secret"; + +@Component({ + selector: 'app-device-rpk-modal', + templateUrl: './device-rpk-modal.component.html', + styleUrls: ['./device-rpk-modal.component.scss'] +}) +export class DeviceRpkModalComponent implements OnInit { + + @Input() public rpkSecret: Secret = new Secret(); + @Input() public isNewCredentials: boolean = true; + + @Output() public rpkSecretChanged: EventEmitter<{secret: Secret|undefined, usePublicKey: boolean}> = new EventEmitter<{secret: Secret|undefined, usePublicKey: boolean}>(); + + public usePublicKey: boolean = true; + public notBefore: boolean = false; + public notAfter: boolean = false; + public algorithmTypes: string[] = ['EC', 'RSA']; + + public ngOnInit() { + if (!this.rpkSecret) { + this.rpkSecret = new Secret(); + } + if (this.rpkSecret["not-before"]) { + this.notBefore = true; + } + if (this.rpkSecret["not-after"]) { + this.notAfter = true; + } + } + + public setUsePublicKey(usePublicKey: boolean) { + this.usePublicKey = usePublicKey; + this.rpkSecretChanged.emit({secret: this.rpkSecret, usePublicKey: usePublicKey}); + } + + public onRpkSecretChanged() { + if (this.isInvalid()) { + this.rpkSecretChanged.emit({secret: undefined, usePublicKey: this.usePublicKey}); + } else { + this.rpkSecretChanged.emit({secret: this.rpkSecret, usePublicKey: this.usePublicKey}); + } + } + + public setNotBeforeDateTime($event: any) { + this.rpkSecret["not-before"] = this.setDateTime($event); + this.onRpkSecretChanged(); + } + + public setNotAfterDateTime($event: any) { + this.rpkSecret["not-after"] = this.setDateTime($event); + this.onRpkSecretChanged(); + } + + public onChangeNotBefore() { + if (!this.notBefore) { + delete this.rpkSecret["not-before"]; + } + } + + public onChangeNotAfter() { + if (!this.notAfter) { + delete this.rpkSecret["not-after"]; + } + } + + private setDateTime($event: any): string | undefined { + if (!$event || !$event.date || !$event.time || $event.time.hour === undefined || $event.time.minute === undefined || $event.time.second === undefined) { + return undefined; + } + + const date = new Date($event.date.year, $event.date.month, $event.date.day); + date.setHours(Number($event.time.hour)); + date.setMinutes(Number($event.time.minute)); + date.setSeconds($event.time.second); + return date.toISOString(); + } + + private isInvalid(): boolean { + if (this.usePublicKey) { + return !this.rpkSecret.key || !this.rpkSecret.algorithm; + } else { + return !this.rpkSecret.cert; + } + } + +} diff --git a/device-management-ui/src/app/components/modals/delete/delete.component.html b/device-management-ui/src/app/components/modals/delete/delete.component.html new file mode 100644 index 00000000..8c2ea31b --- /dev/null +++ b/device-management-ui/src/app/components/modals/delete/delete.component.html @@ -0,0 +1,21 @@ + diff --git a/device-management-ui/src/app/components/modals/delete/delete.component.scss b/device-management-ui/src/app/components/modals/delete/delete.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/delete/delete.component.spec.ts b/device-management-ui/src/app/components/modals/delete/delete.component.spec.ts new file mode 100644 index 00000000..6c01e035 --- /dev/null +++ b/device-management-ui/src/app/components/modals/delete/delete.component.spec.ts @@ -0,0 +1,59 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {DeleteComponent} from './delete.component'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {ModalHeadComponent} from "../modal-head/modal-head.component"; +import {ModalFooterComponent} from "../modal-footer/modal-footer.component"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; + +describe('DeleteComponent', () => { + let component: DeleteComponent; + let fixture: ComponentFixture; + let activeModalSpy: jasmine.SpyObj; + + beforeEach(async () => { + activeModalSpy = jasmine.createSpyObj('NgbActiveModal', ['close']); + await TestBed.configureTestingModule({ + imports: [FontAwesomeTestingModule], + declarations: [DeleteComponent, ModalHeadComponent, ModalFooterComponent], + providers: [ + {provide: NgbActiveModal, useValue: activeModalSpy} + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeleteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close the active modal on cancel', () => { + component['onCancel'](); + expect(activeModalSpy.close).toHaveBeenCalled(); + }); + + it('should close the active modal on confirm and return true', () => { + component['onConfirm'](); + expect(activeModalSpy.close).toHaveBeenCalledOnceWith(true); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/delete/delete.component.ts b/device-management-ui/src/app/components/modals/delete/delete.component.ts new file mode 100644 index 00000000..93b56b1f --- /dev/null +++ b/device-management-ui/src/app/components/modals/delete/delete.component.ts @@ -0,0 +1,41 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: 'app-delete', + templateUrl: './delete.component.html', + styleUrls: ['./delete.component.scss'] +}) +export class DeleteComponent { + + @Input() public modalTitle: string = ''; + @Input() public body: string = ''; + @Input() public unbind: boolean = false; + + constructor(private activeModal: NgbActiveModal) { + } + + public onConfirm() { + this.activeModal.close(true); + } + + public onCancel() { + this.activeModal.close(); + } + +} diff --git a/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.html b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.html new file mode 100644 index 00000000..dfcd9ee6 --- /dev/null +++ b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.html @@ -0,0 +1,20 @@ + diff --git a/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.scss b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.spec.ts b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.spec.ts new file mode 100644 index 00000000..2aa71fa8 --- /dev/null +++ b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.spec.ts @@ -0,0 +1,52 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ModalFooterComponent} from './modal-footer.component'; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; + +describe('ModalFooterComponent', () => { + let component: ModalFooterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FontAwesomeTestingModule], + declarations: [ModalFooterComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalFooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit confirmButtonPressed event when confirm() is called', () => { + spyOn(component.confirmButtonPressed, 'emit'); + component['confirm'](); + expect(component.confirmButtonPressed.emit).toHaveBeenCalledWith(true); + }); + + it('should emit cancelButtonPressed event when cancel() is called', () => { + spyOn(component.cancelButtonPressed, 'emit'); + component['cancel'](); + expect(component.cancelButtonPressed.emit).toHaveBeenCalledWith(true); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.ts b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.ts new file mode 100644 index 00000000..fe3a6576 --- /dev/null +++ b/device-management-ui/src/app/components/modals/modal-footer/modal-footer.component.ts @@ -0,0 +1,39 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, Output} from '@angular/core'; + +@Component({ + selector: 'app-modal-footer', + templateUrl: './modal-footer.component.html', + styleUrls: ['./modal-footer.component.scss'] +}) +export class ModalFooterComponent { + + @Input() public confirmButtonLabel: string = ''; + @Input() public buttonDisabled: boolean = false; + @Input() public showRequiredFieldInfo: boolean = true; + + @Output() public confirmButtonPressed: EventEmitter = new EventEmitter(); + @Output() public cancelButtonPressed: EventEmitter = new EventEmitter(); + + public confirm() { + this.confirmButtonPressed.emit(true); + } + + public cancel() { + this.cancelButtonPressed.emit(true); + } +} diff --git a/device-management-ui/src/app/components/modals/modal-head/modal-head.component.html b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.html new file mode 100644 index 00000000..784a16e8 --- /dev/null +++ b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.html @@ -0,0 +1,4 @@ + diff --git a/device-management-ui/src/app/components/modals/modal-head/modal-head.component.scss b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/modal-head/modal-head.component.spec.ts b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.spec.ts new file mode 100644 index 00000000..d7c66ef5 --- /dev/null +++ b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.spec.ts @@ -0,0 +1,45 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {ModalHeadComponent} from './modal-head.component'; + +describe('ModalHeadComponent', () => { + let component: ModalHeadComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ModalHeadComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalHeadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit the closeModal event when cancel() is called', () => { + spyOn(component.closeModal, 'emit'); + component['cancel'](); + expect(component.closeModal.emit).toHaveBeenCalledWith(true); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/modal-head/modal-head.component.ts b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.ts new file mode 100644 index 00000000..24f2531e --- /dev/null +++ b/device-management-ui/src/app/components/modals/modal-head/modal-head.component.ts @@ -0,0 +1,33 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, Output} from '@angular/core'; + +@Component({ + selector: 'app-modal-head', + templateUrl: './modal-head.component.html', + styleUrls: ['./modal-head.component.scss'] +}) +export class ModalHeadComponent { + + @Input() public modalTitle: string = ''; + + @Output() public closeModal: EventEmitter = new EventEmitter(); + + public cancel() { + this.closeModal.emit(true); + } + +} diff --git a/device-management-ui/src/app/components/modals/select-devices/select-devices.component.html b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.html new file mode 100644 index 00000000..1906b19d --- /dev/null +++ b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.html @@ -0,0 +1,39 @@ +
+
+
+
+ + +
+
+
+
+ + * +
+
+ +
+
+ + + + + + +
+ +
+
+ + +
diff --git a/device-management-ui/src/app/components/modals/select-devices/select-devices.component.scss b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.scss new file mode 100644 index 00000000..df95d6dc --- /dev/null +++ b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.scss @@ -0,0 +1,50 @@ +.selectedDevicesList { + flex-flow: row wrap; + align-content: flex-start; + justify-content: flex-start; +} + +.selectedDevicesItem { + display: inline-block; + margin-right: 1rem; +} + +.selectedDevicesItem > div { + background-color: var(--alert); + border: 1px solid #ccc; + border-radius: 0.375rem; + margin: 0.3rem 1rem; + padding: 0.3rem 1rem; + text-align: center; +} + +.selectedDevicesItem fa-icon { + padding: 0 5px 0 10px; + &:hover { + cursor: pointer; + } +} + +.card-table-view { + max-height: calc(70vh - 330px); + overflow-y: auto; + + tr { + &:hover { + cursor: pointer; + } + } + + .selectedDevicesList { + max-height: 100px; + overflow-y: auto; + } +} + +table.table { + margin-bottom: 0; +} + +ngb-pagination { + padding-top: 0 !important; +} \ No newline at end of file diff --git a/device-management-ui/src/app/components/modals/select-devices/select-devices.component.spec.ts b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.spec.ts new file mode 100644 index 00000000..41dd76dd --- /dev/null +++ b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.spec.ts @@ -0,0 +1,145 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SelectDevicesComponent } from './select-devices.component'; +import {HttpClientModule} from "@angular/common/http"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {Device} from "../../../models/device"; + +describe('SelectDevicesComponent', () => { + let component: SelectDevicesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ SelectDevicesComponent ], + imports: [HttpClientModule, OAuthModule.forRoot()] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SelectDevicesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return true when devices list is empty', () => { + component.devices = []; + + expect(component.deviceListIsEmpty()).toBeTrue(); + }); + + it('should return false when devices list is not empty', () => { + component.devices = [new Device()]; + + expect(component.deviceListIsEmpty()).toBeFalse(); + }); + + it('should return true when device.via list is empty', () => { + component.device.via = []; + + expect(component.devicesSelected()).toBeTrue(); + }); + + it('should return false when device.via list is not empty', () => { + component.device.via = ['gatewayId']; + + expect(component.devicesSelected()).toBeFalse(); + }); + + it('should return true when selectedDevices list is empty', () => { + component.selectedDevices = []; + + expect(component.selectedDevicesListEmpty()).toBeTrue(); + }); + + it('should return false when selectedDevices list is not empty', () => { + component.selectedDevices = [new Device()]; + + expect(component.selectedDevicesListEmpty()).toBeFalse(); + }); + + it('should add the selected device to selectedDevices array and emit the event', () => { + const device = new Device(); + device.id = 'test-device-id'; + + spyOn(component.selectedDevicesChanged, 'emit'); + + component.selectDevice(device); + + expect(device.checked).toBeTrue(); + expect(component.selectedDevices).toContain(device); + expect(component.selectedDevicesChanged.emit).toHaveBeenCalledWith(component.selectedDevices); + }); + + it('should not add the same selected device to selectedDevices array multiple times', () => { + const device = new Device(); + device.id = 'test-device-id'; + + spyOn(component.selectedDevicesChanged, 'emit'); + + component.selectDevice(device); + component.selectDevice(device); + component.selectDevice(device); + + expect(component.selectedDevices).toContain(device); + expect(component.selectedDevices.length).toBe(1); + expect(component.selectedDevicesChanged.emit).toHaveBeenCalledTimes(1); + }); + + it('should remove the selected device from selectedDevice array and emit the event', () => { + const device = new Device(); + device.id = 'test-device-id'; + component.selectedDevices= [device]; + + spyOn(component.selectedDevicesChanged, 'emit'); + + component.unselectDevice(device); + + expect(device.checked).toBeFalse(); + expect(component.selectedDevices).not.toContain(device); + expect(component.selectedDevicesChanged.emit).toHaveBeenCalledWith(component.selectedDevices); + }) + + it('should not emit the event if the device is not found in selectedDevices array', () => { + const device = new Device(); + device.id = 'test-device-id'; + + spyOn(component.selectedDevicesChanged, 'emit'); + + component.unselectDevice(device); + + expect(component.selectedDevicesChanged.emit).not.toHaveBeenCalled(); + }); + + it('should update the pageOffset and emit the event when changing the page', () => { + component['pageOffset'] = 50; + const pageSize = 10; + + spyOn(component.pageOffsetChanged, 'emit'); + + component.pageSize = pageSize; + component.changePage(6); + + expect(component['pageOffset']).toBe(50); + expect(component.pageOffsetChanged.emit).toHaveBeenCalledWith(50); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/select-devices/select-devices.component.ts b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.ts new file mode 100644 index 00000000..0c924300 --- /dev/null +++ b/device-management-ui/src/app/components/modals/select-devices/select-devices.component.ts @@ -0,0 +1,77 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {Device} from "../../../models/device"; +@Component({ + selector: 'app-selected-devices', + templateUrl: './select-devices.component.html', + styleUrls: ['./select-devices.component.scss'] +}) +export class SelectDevicesComponent { + + @Input() public device: Device = new Device(); + @Input() public sendViaGateway: boolean = false; + @Input() public tenantId: string = ''; + @Input() public devices: Device[] = []; + @Input() public deviceListCount: number = 0; + @Input() public createDevice: boolean = false; + @Input() public pageSize: number = 50; + @Input() public bindDevices: boolean = false; + @Input() public selectDevicesLabel: string = ''; + + @Output() public pageOffsetChanged: EventEmitter = new EventEmitter(); + @Output() public selectedDevicesChanged: EventEmitter = new EventEmitter(); + + public selectedDevices: Device[] = []; + public searchTerm!: string; + + private pageOffset: number = 0; + + public selectDevice(selectedDevice: Device) { + selectedDevice.checked = true; + if (!this.selectedDevices.includes(selectedDevice)) { + this.selectedDevices.push(selectedDevice); + this.selectedDevicesChanged.emit(this.selectedDevices); + } + } + + public unselectDevice(selectedDevice: Device) { + selectedDevice.checked = false; + const index = this.selectedDevices.indexOf(selectedDevice); + if (index != undefined && index >= 0) { + this.selectedDevices.splice(index, 1); + this.selectedDevicesChanged.emit(this.selectedDevices); + } + } + + public deviceListIsEmpty(): boolean { + return !this.devices || this.devices.length === 0; + } + + public devicesSelected(): boolean { + return !this.device.via || this.device.via.length === 0; + } + + public selectedDevicesListEmpty(): boolean { + return !this.selectedDevices || this.selectedDevices.length === 0; + } + + public changePage($event: number) { + this.pageOffset = ($event -1) * this.pageSize; + this.pageOffsetChanged.emit(this.pageOffset); + } + +} diff --git a/device-management-ui/src/app/components/modals/send-command/send-command.component.html b/device-management-ui/src/app/components/modals/send-command/send-command.component.html new file mode 100644 index 00000000..48835025 --- /dev/null +++ b/device-management-ui/src/app/components/modals/send-command/send-command.component.html @@ -0,0 +1,63 @@ + diff --git a/device-management-ui/src/app/components/modals/send-command/send-command.component.scss b/device-management-ui/src/app/components/modals/send-command/send-command.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/send-command/send-command.component.spec.ts b/device-management-ui/src/app/components/modals/send-command/send-command.component.spec.ts new file mode 100644 index 00000000..4df69597 --- /dev/null +++ b/device-management-ui/src/app/components/modals/send-command/send-command.component.spec.ts @@ -0,0 +1,118 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {SendCommandComponent} from './send-command.component'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {ModalHeadComponent} from "../modal-head/modal-head.component"; +import {ModalFooterComponent} from "../modal-footer/modal-footer.component"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {FormsModule} from "@angular/forms"; +import {Command} from "../../../models/command"; +import {CommandService} from "../../../services/command/command.service"; +import {of, throwError} from "rxjs"; +import {NotificationService} from "../../../services/notification/notification.service"; + +describe('SendCommandComponent', () => { + let component: SendCommandComponent; + let fixture: ComponentFixture; + let activeModalSpy: jasmine.SpyObj; + let commandService: CommandService; + let notificationService: NotificationService; + + beforeEach(async () => { + activeModalSpy = jasmine.createSpyObj('NgbActiveModal', ['close']); + notificationService = jasmine.createSpyObj('NotificationService', ['error']); + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, FormsModule], + declarations: [SendCommandComponent, ModalHeadComponent, ModalFooterComponent], + providers: [ + {provide: NgbActiveModal, useValue: activeModalSpy}, + CommandService, + {provide: NotificationService, useValue: notificationService}, + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SendCommandComponent); + component = fixture.componentInstance; + commandService = TestBed.inject(CommandService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set useText to true when event target value is "text"', () => { + const event = {target: {value: 'text'}}; + component['onChange'](event); + + expect(component['useText']).toBeTrue(); + }); + + it('should set useText to false when event target value is not "text"', () => { + const event = {target: {value: 'otherValue'}}; + component['onChange'](event); + + expect(component['useText']).toBeFalse(); + }); + + it('should not do anything when any required property is missing', () => { + component['onConfirm'](); + + expect(notificationService.error).not.toHaveBeenCalled(); + expect(activeModalSpy.close).not.toHaveBeenCalled(); + }); + + it('should send command and close active modal', () => { + const command = new Command(); + command.binaryData = 'testBinaryData'; + + component.deviceId = 'device-id'; + component.tenantId = 'tenant-id'; + component['command'] = command; + + spyOn(commandService, 'sendCommand').and.returnValue(of({})); + component['onConfirm'](); + + expect(commandService.sendCommand).toHaveBeenCalledWith('device-id', 'tenant-id', command); + expect(activeModalSpy.close).toHaveBeenCalledWith(command); + }); + + it('should throw error on sendCommand and show notification', () => { + const command = new Command(); + command.binaryData = 'testBinaryData'; + + component.deviceId = 'device-id'; + component.tenantId = 'tenant-id'; + component['command'] = command; + + spyOn(commandService, 'sendCommand').and.returnValue(throwError({error: {error: 'error'}, message: 'error message'})); + component['onConfirm'](); + + expect(commandService.sendCommand).toHaveBeenCalledWith('device-id', 'tenant-id', command); + expect(activeModalSpy.close).not.toHaveBeenCalled(); + expect(notificationService.error).toHaveBeenCalledWith('Could not send command to device device-id. Reason: error'); + }); + + it('should close the active modal', () => { + component['onClose'](); + expect(activeModalSpy.close).toHaveBeenCalled(); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/send-command/send-command.component.ts b/device-management-ui/src/app/components/modals/send-command/send-command.component.ts new file mode 100644 index 00000000..a6c34c96 --- /dev/null +++ b/device-management-ui/src/app/components/modals/send-command/send-command.component.ts @@ -0,0 +1,97 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {Command} from "../../../models/command"; +import {CommandService} from "../../../services/command/command.service"; +import {NotificationService} from "../../../services/notification/notification.service"; + +@Component({ + selector: 'app-send-command', + templateUrl: './send-command.component.html', + styleUrls: ['./send-command.component.scss'] +}) +export class SendCommandComponent { + + @Input() public deviceId: string = ''; + @Input() public tenantId: string = ''; + + public correlationId: number | undefined; + public requestResponseCommandEnabled: boolean = false; + public correlationIdEnabled: boolean = false; + public useText: boolean = true; + public command: Command = new Command(); + + constructor(private activeModal: NgbActiveModal, + private commandService: CommandService, + private notificationService: NotificationService) { + } + + public onChange($event: any) { + this.useText = $event.target.value == 'text'; + } + + public onConfirm() { + if (!this.command || !this.command.binaryData || !this.deviceId || !this.tenantId) { + return; + } + this.updateCommandData(); + this.commandService.sendCommand(this.deviceId, this.tenantId, this.command).subscribe(() => { + this.activeModal.close(this.command); + }, (error) => { + console.log(error); + this.command.binaryData = atob(this.command.binaryData); + this.notificationService.error('Could not send command to device '+ this.deviceId.toBold() + '. Reason: ' + error.error.error); + }); + } + + public onChangeRequestResponseCommandEnabled(requestResponseCommandChecked: boolean) { + if (!requestResponseCommandChecked) { + this.correlationIdEnabled = false; + this.correlationId = undefined; + this.command['response-required'] = this.requestResponseCommandEnabled; + this.command['correlation-id'] = this.correlationId; + } + } + + public onChangeCorrelationIdEnabled(correlationIdChecked: boolean) { + if (!correlationIdChecked) { + this.correlationId = undefined; + this.command['correlation-id'] = this.correlationId; + } + } + + private updateCommandData() { + if (this.useText) { + this.command.binaryData = btoa(this.command.binaryData); + } + + if (this.requestResponseCommandEnabled) { + this.command['response-required'] = this.requestResponseCommandEnabled; + } + + if (this.correlationIdEnabled) { + this.command['correlation-id'] = this.correlationId; + } + } + + public onClose() { + this.activeModal.close(); + } + + + +} diff --git a/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.html b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.html new file mode 100644 index 00000000..e0790575 --- /dev/null +++ b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.html @@ -0,0 +1,42 @@ + diff --git a/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.scss b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.spec.ts b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.spec.ts new file mode 100644 index 00000000..03f17a7d --- /dev/null +++ b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.spec.ts @@ -0,0 +1,156 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {TenantModalComponent} from './tenant-modal.component'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {ModalHeadComponent} from "../modal-head/modal-head.component"; +import {NgSelectModule} from "@ng-select/ng-select"; +import {ModalFooterComponent} from "../modal-footer/modal-footer.component"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {FormsModule} from "@angular/forms"; +import {TenantService} from "../../../services/tenant/tenant.service"; +import {of, throwError} from "rxjs"; +import {NotificationService} from "../../../services/notification/notification.service"; + +describe('CreateTenantComponent', () => { + let component: TenantModalComponent; + let fixture: ComponentFixture; + let activeModalSpy: jasmine.SpyObj; + let tenantServiceSpy: jasmine.SpyObj; + let notificationServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + activeModalSpy = jasmine.createSpyObj('NgbActiveModal', ['close']); + tenantServiceSpy = jasmine.createSpyObj('TenantService', ['create', 'update']); + notificationServiceSpy = jasmine.createSpyObj('NotificationService', ['error']); + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), NgSelectModule, FontAwesomeTestingModule, FormsModule], + declarations: [TenantModalComponent, ModalHeadComponent, ModalFooterComponent], + providers: [ + {provide: NgbActiveModal, useValue: activeModalSpy}, + {provide: TenantService, useValue: tenantServiceSpy}, + {provide: NotificationService, useValue: notificationServiceSpy} + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TenantModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close the active modal', () => { + component['onClose'](); + expect(activeModalSpy.close).toHaveBeenCalled(); + }); + + it('should set modal title to create Tenant if isNewTenant is true', () => { + component.isNewTenant = true; + component.ngOnInit(); + expect(component['modalTitle']).toEqual('Create Tenant'); + expect(component.tenant.ext).toEqual({'messaging-type': ''}); + }); + + it('should set modal title to Edit Tenant if isNewTenant is false', () => { + component.tenant = { + id: 'tenant-id', + ext: {'messaging-type': 'pubsub'} + }; + component.isNewTenant = false; + component.ngOnInit(); + expect(component['modalTitle']).toEqual('Edit Tenant'); + expect(component.tenant.ext).toEqual({'messaging-type': 'pubsub'}); + }); + + it('should call createTenant when isNewTenant is true', () => { + component.isNewTenant = true; + component.tenant = { + id: 'test-id', + ext: {'messaging-type': 'pubsub'} + }; + + tenantServiceSpy.create.and.returnValue(of(true)); + + component['onConfirm'](); + + expect(tenantServiceSpy.create).toHaveBeenCalledWith(component.tenant); + expect(activeModalSpy.close).toHaveBeenCalledWith(component.tenant); + expect(tenantServiceSpy.update).not.toHaveBeenCalledWith(component.tenant); + }); + + it('should call updateTenant when isNewTenant is false', () => { + component.isNewTenant = false; + component.tenant = { + id: 'test-id', + ext: {'messaging-type': 'pubsub'} + }; + + tenantServiceSpy.update.and.returnValue(of(true)); + + component['onConfirm'](); + + expect(tenantServiceSpy.create).not.toHaveBeenCalledWith(component.tenant); + expect(tenantServiceSpy.update).toHaveBeenCalledWith(component.tenant); + expect(activeModalSpy.close).toHaveBeenCalledWith(component.tenant); + }); + + it('should call createTenant and give an error notification', () => { + component.isNewTenant = true; + component.tenant = { + id: 'tenant-id', + ext: {'messaging-type': 'pubsub'} + }; + + tenantServiceSpy.create.and.returnValue(throwError(new Error('Create failed'))); + + component['onConfirm'](); + + expect(notificationServiceSpy.error).toHaveBeenCalledWith('Could not create tenant'); + }); + + it('should call updateTenant and give an error notification', () => { + component.isNewTenant = false; + component.tenant = { + id: 'tenant-id', + ext: {'messaging-type': 'pubsub'} + }; + + tenantServiceSpy.update.and.returnValue(throwError(new Error('Update failed'))); + + component['onConfirm'](); + + expect(notificationServiceSpy.error).toHaveBeenCalledWith('Could not update tenant'); + }); + + it('should not call any methods when tenant data is invalid', () => { + component.tenant = { + id: '', + ext: {'messaging-type': ''} + }; + + component['onConfirm'](); + + expect(tenantServiceSpy.create).not.toHaveBeenCalledWith(component.tenant); + expect(tenantServiceSpy.update).not.toHaveBeenCalledWith(component.tenant); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.ts b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.ts new file mode 100644 index 00000000..5e0a04da --- /dev/null +++ b/device-management-ui/src/app/components/modals/tenant/tenant-modal.component.ts @@ -0,0 +1,98 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input, OnInit} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {Tenant} from "../../../models/tenant"; +import {TenantService} from "../../../services/tenant/tenant.service"; +import {NotificationService} from "../../../services/notification/notification.service"; + +@Component({ + selector: 'app-tenant-modal', + templateUrl: './tenant-modal.component.html', + styleUrls: ['./tenant-modal.component.scss'], + +}) +export class TenantModalComponent implements OnInit { + + @Input() public isNewTenant: boolean = true; + @Input() public tenant: Tenant = new Tenant(); + + public tenantIdLabel = 'Tenant ID'; + public modalTitle: string = ''; + public messagingTypes: { + key: string, + value: string, + }[] = [ + {key: 'pubsub', value: 'Pub/Sub'}, + {key: 'kafka', value: 'Kafka'}, + {key: 'amqp', value: 'AMQP'} + ] + + constructor(private activeModal: NgbActiveModal, + private tenantService: TenantService, + private notificationService: NotificationService) { + } + + public ngOnInit() { + if (this.isNewTenant) { + this.modalTitle = 'Create Tenant'; + } else { + this.modalTitle = 'Edit Tenant'; + } + if (!this.tenant.ext) { + this.tenant.ext = {'messaging-type': ''}; + } + } + + public onConfirm() { + if (this.isInvalid()) { + return; + } + if (this.isNewTenant) { + this.createTenant(); + } else { + this.updateTenant(); + } + } + + public onClose() { + this.activeModal.close(); + } + + public isInvalid() { + return !this.tenant || !this.tenant.id || !this.tenant.ext || !this.tenant.ext['messaging-type']; + } + + private createTenant() { + this.tenantService.create(this.tenant).subscribe((result) => { + if (result) { + this.activeModal.close(this.tenant); + } + }, (error) => { + console.log('Error saving tenant ', this.tenant.id, error); + this.notificationService.error('Could not create tenant'); + }); + } + + private updateTenant() { + this.tenantService.update(this.tenant).subscribe(() => { + this.activeModal.close(this.tenant); + }, (error) => { + console.log('Error saving tenant ' + this.tenant.id, error); + this.notificationService.error('Could not update tenant'); + }); + } +} diff --git a/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.html b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.html new file mode 100644 index 00000000..e0af9a53 --- /dev/null +++ b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.html @@ -0,0 +1,39 @@ + diff --git a/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.scss b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.spec.ts b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.spec.ts new file mode 100644 index 00000000..c64e1dbb --- /dev/null +++ b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.spec.ts @@ -0,0 +1,117 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {UpdateConfigModalComponent} from './update-config-modal.component'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {ModalHeadComponent} from "../modal-head/modal-head.component"; +import {ModalFooterComponent} from "../modal-footer/modal-footer.component"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {FormsModule} from "@angular/forms"; +import {ConfigService} from "../../../services/config/config.service"; +import {Config, ConfigRequest} from "../../../models/config"; +import {of} from "rxjs"; +import {NotificationService} from "../../../services/notification/notification.service"; + +describe('UpdateConfigModalComponent', () => { + let component: UpdateConfigModalComponent; + let fixture: ComponentFixture; + let activeModalSpy: jasmine.SpyObj; + let mockConfigService: jasmine.SpyObj; + let mockNotificationService: jasmine.SpyObj; + + beforeEach(async () => { + activeModalSpy = jasmine.createSpyObj('NgbActiveModal', ['close']); + mockConfigService = jasmine.createSpyObj('ConfigService', ['updateConfig']); + mockNotificationService = jasmine.createSpyObj('NotificationService', ['error']); + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, FormsModule], + declarations: [UpdateConfigModalComponent, ModalHeadComponent, ModalFooterComponent], + providers: [ + {provide: NgbActiveModal, useValue: activeModalSpy}, + {provide: ConfigService, useValue: mockConfigService}, + {provide: NotificationService, useValue: mockNotificationService} + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpdateConfigModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close the active modal', () => { + component['onClose'](); + expect(activeModalSpy.close).toHaveBeenCalled(); + }); + + it('should return false when event has not property value "text"', () => { + const event = {target: {val: 'not value'}}; + component['onChange'](event); + expect(component['useText']).toBeFalse(); + }); + + it('should return true when event has property value "text"', () => { + const event = {target: {value: 'text'}}; + component['onChange'](event); + expect(component['useText']).toBeTrue(); + }); + + it('should not do anything when tenantId is not defined', () => { + component.deviceId = 'deviceID'; + component['config'] = { + binaryData: 'test data' + }; + + const updateConfigSpy = mockConfigService.updateConfig.and.callThrough(); + + component['onConfirm'](); + + expect(updateConfigSpy).not.toHaveBeenCalled(); + }); + + it('should update the config and close the modal with the result', () => { + const mockConfig: ConfigRequest = { + binaryData: 'test data' + }; + + const expectedUpdatedConfig: Config = { + version: '2', + cloudUpdateTime: 'today', + binaryData: 'test data', + deviceAckTime: '' + }; + + component['config'] = mockConfig; + component.deviceId = 'deviceID'; + component.tenantId = 'tenantID'; + + const updateConfigSpy = mockConfigService.updateConfig.and.returnValue(of(expectedUpdatedConfig)); + + component['onConfirm'](); + + expect(updateConfigSpy).toHaveBeenCalledWith(component.deviceId, component.tenantId, mockConfig); + expect(component['config'].binaryData).toEqual('dGVzdCBkYXRh'); + expect(activeModalSpy.close).toHaveBeenCalledWith(expectedUpdatedConfig); + expect(mockNotificationService.error).not.toHaveBeenCalled(); + }); + +}); diff --git a/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.ts b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.ts new file mode 100644 index 00000000..1487f036 --- /dev/null +++ b/device-management-ui/src/app/components/modals/update-config-modal/update-config-modal.component.ts @@ -0,0 +1,99 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, Input, OnInit} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {Config, ConfigRequest} from "../../../models/config"; +import {ConfigService} from "../../../services/config/config.service"; +import {NotificationService} from "../../../services/notification/notification.service"; + +@Component({ + selector: 'app-update-config-modal', + templateUrl: './update-config-modal.component.html', + styleUrls: ['./update-config-modal.component.scss'] +}) + +export class UpdateConfigModalComponent implements OnInit{ + + @Input() public deviceId: string = ''; + @Input() public tenantId: string = ''; + @Input() public showConfig: boolean = false; + @Input() public savedConfig: Config = new Config(); + + public modalTitle: string = 'Update config'; + public body: string = 'Enter the new configuration below. If you\'re using MQTT, the configuration will be propagated to the device when it connects.'; + public useText: boolean = true; + public config: ConfigRequest = new ConfigRequest(); + public textToShow: string = ''; + private textString: string = 'text'; + private savedConfigString: string = 'Saved configuration'; + + constructor(private activeModal: NgbActiveModal, + private configService: ConfigService, + private notificationService: NotificationService) { + } + + ngOnInit() { + if (this.showConfig && this.savedConfig.binaryData != '') { + this.config = this.savedConfig; + this.modalTitle = this.savedConfigString; + this.textToShow = atob(this.config.binaryData); + } + } + + public onChange($event: any) { + if ($event.target.value === this.textString) { + this.textToShow = atob(this.config.binaryData); + } else { + this.textToShow = this.config.binaryData; + } + this.useText = $event.target.value == 'text'; + } + + public onConfirm() { + if (!this.config || !this.config.binaryData || !this.deviceId || !this.tenantId) { + return; + } + this.updateConfigData(); + this.configService.updateConfig(this.deviceId, this.tenantId, this.config).subscribe((result: Config) => { + if (result) { + this.activeModal.close(result); + } + }, (error) => { + console.log('Error updating config', error); + this.notificationService.error('Could not update config for device ' + this.deviceId.toBold() + '. Reason: ' + error.error.error); + }) + } + + public onClose() { + this.activeModal.close(); + } + + public updateConfig() { + if (this.useText) { + this.config.binaryData = btoa(this.textToShow); + } else { + this.config.binaryData = this.textToShow; + } + } + + private updateConfigData() { + if (this.useText) { + this.config.binaryData = btoa(this.textToShow); + } + } + + +} diff --git a/device-management-ui/src/app/components/pagination/pagination.component.html b/device-management-ui/src/app/components/pagination/pagination.component.html new file mode 100644 index 00000000..010dbeeb --- /dev/null +++ b/device-management-ui/src/app/components/pagination/pagination.component.html @@ -0,0 +1,25 @@ +
+ + +
+ + +
+
diff --git a/device-management-ui/src/app/components/pagination/pagination.component.scss b/device-management-ui/src/app/components/pagination/pagination.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/pagination/pagination.component.spec.ts b/device-management-ui/src/app/components/pagination/pagination.component.spec.ts new file mode 100644 index 00000000..315c19d9 --- /dev/null +++ b/device-management-ui/src/app/components/pagination/pagination.component.spec.ts @@ -0,0 +1,38 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PaginationComponent } from './pagination.component'; + +describe('PaginationComponent', () => { + let component: PaginationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ PaginationComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PaginationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/components/pagination/pagination.component.ts b/device-management-ui/src/app/components/pagination/pagination.component.ts new file mode 100644 index 00000000..1016fd50 --- /dev/null +++ b/device-management-ui/src/app/components/pagination/pagination.component.ts @@ -0,0 +1,42 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, EventEmitter, Input, Output} from '@angular/core'; + +@Component({ + selector: 'app-pagination', + templateUrl: './pagination.component.html', + styleUrls: ['./pagination.component.scss'] +}) +export class PaginationComponent { + @Input() public deviceListCount: number = 0; + + @Output() public pageNumberChange = new EventEmitter(); + @Output() public pageSizeChange = new EventEmitter(); + + public pageSize: number = 50; + public pageSizeOptions: number[] = [50, 100, 200]; + + changePage($event: number) { + this.pageNumberChange.emit($event) + } + onPageSizeChange(event: any) { + const {value} = event.target; + if (+value) { + this.pageSizeChange.emit(+value); + } + } + +} diff --git a/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.html b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.html new file mode 100644 index 00000000..a354183e --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.html @@ -0,0 +1,82 @@ + + + + +
+ +
+
+
+ ID: +
+
+ +
+
+ +
+
+ Messaging-Type: +
+
+ +
+
+ +
+ +
+
+ +
+
diff --git a/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.scss b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.scss new file mode 100644 index 00000000..4953133b --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.scss @@ -0,0 +1,7 @@ +.detail-label { + min-width: 100px; + + > span { + font-weight: bold; + } +} \ No newline at end of file diff --git a/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.spec.ts b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.spec.ts new file mode 100644 index 00000000..88050bea --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.spec.ts @@ -0,0 +1,74 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {TenantDetailComponent} from './tenant-detail.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {ListConfigComponent} from "../../devices/device-detail/list-config/list-config.component"; +import {NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {DeviceListComponent} from "../../devices/device-list/device-list.component"; +import {Router} from "@angular/router"; +import {RouterTestingModule} from "@angular/router/testing"; + +describe('TenantDetailComponent', () => { + let component: TenantDetailComponent; + let fixture: ComponentFixture; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, NgbModule, RouterTestingModule], + declarations: [TenantDetailComponent, DeviceListComponent, ListConfigComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TenantDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return the correct tenant detail', () => { + component['tenant'] = { + id: 'test-tenant', + ext: {} + }; + expect(component['tenantDetail']).toEqual('Tenant: test-tenant'); + }); + + it('should return the messaging-type value if it is defined in the tenant extension', () => { + component['tenant'].ext = { + 'messaging-type': 'pubsub' + }; + expect(component['messagingType']).toEqual('pubsub'); + }); + + it('should return "-" if the messaging-type property is not defined in the tenant extension', () => { + expect(component['messagingType']).toEqual('-'); + }); + + it('should navigate back to tenant list', () => { + component['navigateBack'](); + expect(router.navigate).toHaveBeenCalledWith(['tenant-list']); + }); + +}); diff --git a/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.ts b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.ts new file mode 100644 index 00000000..b65aa2e6 --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-detail/tenant-detail.component.ts @@ -0,0 +1,108 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component} from '@angular/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {DeleteComponent} from '../../modals/delete/delete.component'; +import {Router} from "@angular/router"; +import {Tenant} from "../../../models/tenant"; +import {TenantModalComponent} from '../../modals/tenant/tenant-modal.component'; +import {DeviceService} from "../../../services/device/device.service"; +import {TenantService} from "../../../services/tenant/tenant.service"; +import {NotificationService} from "../../../services/notification/notification.service"; +import {Device} from "../../../models/device"; + +@Component({ + selector: 'app-tenant-detail', + templateUrl: './tenant-detail.component.html', + styleUrls: ['./tenant-detail.component.scss'] +}) +export class TenantDetailComponent { + + public tenant: Tenant = new Tenant(); + public deviceListCount: number = 0; + public pageSize: number = 50; + public devices: Device[] = []; + + constructor(private modalService: NgbModal, + private deviceService: DeviceService, + private tenantService: TenantService, + private router: Router, + private notificationService: NotificationService) { + const navigation = this.router.getCurrentNavigation(); + if (navigation) { + const state = navigation.extras.state + if (state && state['tenant']) { + this.tenant = state['tenant']; + this.setActiveTab(false); + } + } + } + + protected get tenantDetail() { + return 'Tenant: ' + this.tenant.id; + } + + protected get messagingType() { + if (this.tenant.ext && this.tenant.ext['messaging-type']) { + return this.tenant.ext['messaging-type']; + } + return '-'; + } + + protected navigateBack() { + this.router.navigate(['tenant-list']); + } + + protected editTenant(): void { + const modalRef = this.modalService.open(TenantModalComponent, {size: 'lg'}); + modalRef.componentInstance.tenant = this.tenant; + modalRef.componentInstance.isNewTenant = false; + modalRef.result.then((res) => { + if (res) { + this.notificationService.success('Successfully edited tenant ' + this.tenant.id.toBold()); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + protected deleteTenant(): void { + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Delete Tenant'; + modalRef.componentInstance.body = 'Do you really want to delete the tenant ' + this.tenant.id.toBold() + '?'; + modalRef.result.then((res) => { + if (res) { + this.delete(); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + private delete() { + this.tenantService.delete(this.tenant.id).subscribe(() => { + this.notificationService.success('Successfully deleted tenant ' + this.tenant.id.toBold()); + this.navigateBack(); + }, (error) => { + console.log(error); + this.notificationService.error('Could not delete tenant ' + this.tenant.id.toBold()); + }) + } + + public setActiveTab(isGateway : boolean){ + this.deviceService.setActiveTab(isGateway); + } +} diff --git a/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.html b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.html new file mode 100644 index 00000000..4add4e1c --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.html @@ -0,0 +1,105 @@ + + + +
+
+ There are no tenants yet. Please create a new tenant. +
+
+
+ + + + + + + + + + + + + + + +
+ Tenant ID + + Messaging-Type + + Actions +
+ + + + + + +
+
+ + + + diff --git a/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.scss b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.scss new file mode 100644 index 00000000..c19af512 --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.scss @@ -0,0 +1,8 @@ +.page-header { + padding-top: 5px; +} + +.card-table-view { + max-height: calc(100vh - 280px); + overflow-y: auto; +} \ No newline at end of file diff --git a/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.spec.ts b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.spec.ts new file mode 100644 index 00000000..f9ea853e --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.spec.ts @@ -0,0 +1,107 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {TenantListComponent} from './tenant-list.component'; +import {HttpClientTestingModule} from "@angular/common/http/testing"; +import {OAuthModule} from "angular-oauth2-oidc"; +import {FontAwesomeTestingModule} from "@fortawesome/angular-fontawesome/testing"; +import {NgbModule} from "@ng-bootstrap/ng-bootstrap"; +import {Router} from "@angular/router"; +import {RouterTestingModule} from "@angular/router/testing"; +import {Tenant} from "../../../models/tenant"; +import {TenantService} from "../../../services/tenant/tenant.service"; +import {of} from "rxjs"; + +describe('TenantListComponent', () => { + let component: TenantListComponent; + let fixture: ComponentFixture; + let tenantServiceSpy: { + list: jasmine.Spy, + delete: jasmine.Spy + }; + const routerSpy = {navigate: jasmine.createSpy('navigate')}; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OAuthModule.forRoot(), FontAwesomeTestingModule, NgbModule, RouterTestingModule], + declarations: [TenantListComponent], + providers: [ + {provide: NgbModule, useValue: {}}, + {provide: Router, useValue: routerSpy},] + }) + .compileComponents(); + }); + + beforeEach(() => { + tenantServiceSpy = jasmine.createSpyObj('TenantService', ['list', 'delete']); + fixture = TestBed.createComponent(TenantListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return messagingType', () => { + const ext = {'messaging-type': 'pubsub'}; + const result = component['getMessagingType'](ext); + expect(result).toEqual('pubsub'); + }); + + it('should return "-"', () => { + const ext = {name: 'pubsub'}; + const result = component['getMessagingType'](ext); + expect(result).toEqual('-'); + }); + + it('should navigate back to tenant detail page with state', () => { + const tenant: Tenant = {id: 'test-id', ext: 'tenant-test'}; + component['selectTenant'](tenant); + expect(routerSpy.navigate).toHaveBeenCalledWith(['tenant-detail', 'test-id'], { + state: {tenant: tenant}, + }); + }); + + it('should set pageOffset and call list method', () => { + const tenantsResponse = { + result: [{id: 'tenant'}, {id: 'tenant2'}], + total: 150 + }; + const listSpy = spyOn(component['tenantService'], 'list').and.returnValue(of(tenantsResponse)); + + component['pageSize'] = 100; + component['changePage'](3); + + expect(listSpy).toHaveBeenCalledWith(100, 200); + expect(component['pageOffset']).toEqual(200); + }); + + it('should set pageSize and call list method', () => { + const tenantsResponse = { + result: [{id: 'tenant'}, {id: 'tenant2'}], + total: 150 + }; + const listSpy = spyOn(component['tenantService'], 'list').and.returnValue(of(tenantsResponse)); + + component['pageOffset'] = 0; + component['setPageSize'](100); + + expect(listSpy).toHaveBeenCalledWith(100, 0); + expect(component['pageSize']).toEqual(100); + }); + +}); diff --git a/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.ts b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.ts new file mode 100644 index 00000000..04c8d0d7 --- /dev/null +++ b/device-management-ui/src/app/components/tenants/tenant-list/tenant-list.component.ts @@ -0,0 +1,155 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, OnInit, QueryList, ViewChildren} from '@angular/core'; +import {Router} from '@angular/router'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {TenantModalComponent} from '../../modals/tenant/tenant-modal.component'; +import {DeleteComponent} from '../../modals/delete/delete.component'; +import {Tenant} from "../../../models/tenant"; +import {TenantService} from "../../../services/tenant/tenant.service"; +import {NotificationService} from 'src/app/services/notification/notification.service'; +import {SortableTableDirective, SortEvent} from "../../../services/sortable-table/sortable-table.directive"; +import {SortableTableService} from "../../../services/sortable-table/sortable-table.service"; + +@Component({ + selector: 'app-tenant-list', + templateUrl: './tenant-list.component.html', + styleUrls: ['./tenant-list.component.scss'] +}) +export class TenantListComponent implements OnInit { + + @ViewChildren(SortableTableDirective) + public sortableHeaders: QueryList = new QueryList(); + + public tenants: Tenant[] = []; + public tenantListCount: number = 0; + public searchTerm!: string; + public pageSize: number = 50; + public pageSizeOptions: number[] = [50, 100, 200]; + + private pageOffset: number = 0; + + constructor(private router: Router, + public modalService: NgbModal, + private notificationService: NotificationService, + private tenantService: TenantService, + private sortableTableService: SortableTableService) { + + } + + public ngOnInit() { + this.listTenants(); + } + + public changePage($event: number) { + this.pageOffset = ($event - 1) * this.pageSize; + this.listTenants(); + } + + public setPageSize(size: number) { + this.pageSize = size; + this.pageOffset = 0; + this.listTenants(); + } + + public onSort({ column, direction }: SortEvent) { + this.sortableHeaders = this.sortableTableService.resetHeaders(this.sortableHeaders, column); + this.tenants = this.sortableTableService.sortItems(this.tenants, {column, direction}); + } + + public getMessagingType(ext: any) { + if (ext && ext['messaging-type']) { + return ext['messaging-type']; + } + return '-'; + } + + public selectTenant(tenant: Tenant) { + this.router.navigate(['tenant-detail', tenant.id], { + state: { + tenant: tenant + } + }); + } + + public createTenant(): void { + const modalRef = this.modalService.open(TenantModalComponent, {size: 'lg'}); + modalRef.componentInstance.modalTitle = 'Create new tenant'; + modalRef.result.then((tenant) => { + if (tenant) { + this.tenants = [tenant, ...this.tenants]; + this.tenantListCount = this.tenantListCount + 1; + this.notificationService.success('Successfully created tenant ' + tenant.id.toBold()); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public editTenant(tenant: Tenant): void { + const modalRef = this.modalService.open(TenantModalComponent, {size: 'lg'}); + modalRef.componentInstance.tenant = tenant; + modalRef.componentInstance.isNewTenant = false; + modalRef.result.then((res) => { + if (res) { + this.notificationService.success('Successfully edited tenant ' + tenant.id.toBold()) + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public deleteTenant(tenant: Tenant): void { + const modalRef = this.modalService.open(DeleteComponent, {ariaLabelledBy: 'modal-basic-title'}); + modalRef.componentInstance.modalTitle = 'Delete tenant ' + tenant.id.toBold(); + modalRef.componentInstance.body = 'Do you really want to delete the tenant ' + tenant.id.toBold() + '?'; + modalRef.result.then((res) => { + if (res) { + this.delete(tenant); + } + }, (reason: any) => { + console.log(`Closed with reason: ${reason}`); + }); + } + + public tenantListIsEmpty(): boolean { + return !this.tenants || this.tenants.length === 0; + } + + private listTenants() { + this.tenantService.list(this.pageSize, this.pageOffset).subscribe(tenants => { + this.tenants = tenants.result; + this.tenantListCount = tenants.total; + }, (err) => { + console.log(err); + this.notificationService.error('Could not retrieve tenant list.'); + }); + } + + private delete(tenant: Tenant) { + this.tenantService.delete(tenant.id).subscribe(() => { + const index = this.tenants.indexOf(tenant); + if (index >= 0) { + this.tenants.splice(index, 1); + this.tenantListCount = this.tenantListCount - 1; + this.notificationService.success('Successfully deleted tenant ' + tenant.id.toBold()); + } + }, (error) => { + console.log(error); + this.notificationService.error('Could not delete tenant ' + tenant.id.toBold()); + }) + } +} diff --git a/device-management-ui/src/app/components/toast-container/toast-container.component.html b/device-management-ui/src/app/components/toast-container/toast-container.component.html new file mode 100644 index 00000000..59daa8b1 --- /dev/null +++ b/device-management-ui/src/app/components/toast-container/toast-container.component.html @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/device-management-ui/src/app/components/toast-container/toast-container.component.scss b/device-management-ui/src/app/components/toast-container/toast-container.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/device-management-ui/src/app/components/toast-container/toast-container.component.spec.ts b/device-management-ui/src/app/components/toast-container/toast-container.component.spec.ts new file mode 100644 index 00000000..6f1c5b5c --- /dev/null +++ b/device-management-ui/src/app/components/toast-container/toast-container.component.spec.ts @@ -0,0 +1,48 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ToastContainerComponent} from './toast-container.component'; +import {NotificationService} from "../../services/notification/notification.service"; + +describe('ToastContainerComponent', () => { + let component: ToastContainerComponent; + let fixture: ComponentFixture; + let notificationServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ToastContainerComponent], + providers: [NotificationService] + }) + .compileComponents(); + notificationServiceSpy = jasmine.createSpyObj('NotificationService', ['notify']); + component = new ToastContainerComponent(notificationServiceSpy); + + fixture = TestBed.createComponent(ToastContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return false when toast is not of instance TemplateRef', () => { + const toast = {textOrTpl: 'some text'}; + expect(component['isTemplate'](toast)).toBeFalse(); + }); + +}); diff --git a/device-management-ui/src/app/components/toast-container/toast-container.component.ts b/device-management-ui/src/app/components/toast-container/toast-container.component.ts new file mode 100644 index 00000000..734658df --- /dev/null +++ b/device-management-ui/src/app/components/toast-container/toast-container.component.ts @@ -0,0 +1,32 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Component, TemplateRef} from '@angular/core'; +import {NotificationService} from "../../services/notification/notification.service"; + +@Component({ + selector: 'toast-container', + templateUrl: './toast-container.component.html', + styleUrls: ['./toast-container.component.scss'], + host: {class: 'toast-container bottom-0 end-0 p-3'}, +}) +export class ToastContainerComponent { + constructor(public notificationService: NotificationService) { + } + + public isTemplate(toast: any) { + return toast.textOrTpl instanceof TemplateRef; + } +} diff --git a/device-management-ui/src/app/models/authentication-value.ts b/device-management-ui/src/app/models/authentication-value.ts new file mode 100644 index 00000000..d691e379 --- /dev/null +++ b/device-management-ui/src/app/models/authentication-value.ts @@ -0,0 +1,33 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {CredentialTypes} from "./credentials/credentials"; + +export class AuthenticationValue { + type?: CredentialTypes | string; + + 'auth-id'?: string; + + 'not-after'?: string; + + 'not-before'?: string; + + algorithm?: string; + + key?: string; + + id?: string; + +} diff --git a/device-management-ui/src/app/models/command.ts b/device-management-ui/src/app/models/command.ts new file mode 100644 index 00000000..45c3665e --- /dev/null +++ b/device-management-ui/src/app/models/command.ts @@ -0,0 +1,21 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export class Command { + binaryData: string = ''; + subfolder: string = ''; + 'response-required'?: boolean; + 'correlation-id'?: number; +} diff --git a/device-management-ui/src/app/models/config.ts b/device-management-ui/src/app/models/config.ts new file mode 100644 index 00000000..f5b4aa04 --- /dev/null +++ b/device-management-ui/src/app/models/config.ts @@ -0,0 +1,25 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export class Config { + version: string = ''; + cloudUpdateTime: string = ''; + deviceAckTime: string = ''; + binaryData: string = ''; +} + +export class ConfigRequest { + binaryData: string = ''; +} diff --git a/device-management-ui/src/app/models/credentials/credentials.ts b/device-management-ui/src/app/models/credentials/credentials.ts new file mode 100644 index 00000000..1d285712 --- /dev/null +++ b/device-management-ui/src/app/models/credentials/credentials.ts @@ -0,0 +1,29 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Secret} from "./secret"; + +export class Credentials { + type?: CredentialTypes | string; + 'auth-id'?: string; + enabled?: boolean; + ext?: any; + secrets: Secret[] = []; +} + +export enum CredentialTypes { + HASHED_PASSWORD = 'hashed-password', + RPK = 'rpk' +} diff --git a/device-management-ui/src/app/models/credentials/secret.ts b/device-management-ui/src/app/models/credentials/secret.ts new file mode 100644 index 00000000..aee2a26d --- /dev/null +++ b/device-management-ui/src/app/models/credentials/secret.ts @@ -0,0 +1,40 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export class Secret { + id?: string; + + enabled?: boolean; + + 'not-before'?: string; + + 'not-after'?: string; + + comment?: string; + + 'hash-function'?: string; + + 'pwd-hash'?: string; + + salt?: string; + + 'pwd-plain'?: string; + + algorithm?: string = ''; + + key?: string = ''; + + cert?: string = ''; +} diff --git a/device-management-ui/src/app/models/device.ts b/device-management-ui/src/app/models/device.ts new file mode 100644 index 00000000..6cf29c2a --- /dev/null +++ b/device-management-ui/src/app/models/device.ts @@ -0,0 +1,22 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export class Device { + id: string = ''; + status: any; + enabled?: boolean; + via?: string[]; + checked?: boolean; +} diff --git a/device-management-ui/src/app/models/environment.ts b/device-management-ui/src/app/models/environment.ts new file mode 100644 index 00000000..43caeaee --- /dev/null +++ b/device-management-ui/src/app/models/environment.ts @@ -0,0 +1,19 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export interface Environment { + production: boolean; + googleClientId: string; +} diff --git a/device-management-ui/src/app/models/state.ts b/device-management-ui/src/app/models/state.ts new file mode 100644 index 00000000..fbbf1fb3 --- /dev/null +++ b/device-management-ui/src/app/models/state.ts @@ -0,0 +1,19 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export class State { + updateTime: string = ''; + binaryData: string = ''; +} diff --git a/device-management-ui/src/app/models/tenant.ts b/device-management-ui/src/app/models/tenant.ts new file mode 100644 index 00000000..d9405d53 --- /dev/null +++ b/device-management-ui/src/app/models/tenant.ts @@ -0,0 +1,19 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +export class Tenant { + id: string = ''; + ext: any; +} diff --git a/device-management-ui/src/app/prototypes/string-prototype.d.ts b/device-management-ui/src/app/prototypes/string-prototype.d.ts new file mode 100644 index 00000000..10114ea7 --- /dev/null +++ b/device-management-ui/src/app/prototypes/string-prototype.d.ts @@ -0,0 +1,18 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +declare interface String { + toBold(): string; +} diff --git a/device-management-ui/src/app/prototypes/string-prototype1.ts b/device-management-ui/src/app/prototypes/string-prototype1.ts new file mode 100644 index 00000000..090c4cbf --- /dev/null +++ b/device-management-ui/src/app/prototypes/string-prototype1.ts @@ -0,0 +1,21 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +String.prototype.toBold = function (): string { + if (!this) { + return ''; + } + return '' + this + ''; +}; diff --git a/device-management-ui/src/app/routing/app-routing.module.ts b/device-management-ui/src/app/routing/app-routing.module.ts new file mode 100644 index 00000000..b3a191fb --- /dev/null +++ b/device-management-ui/src/app/routing/app-routing.module.ts @@ -0,0 +1,40 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {TenantListComponent} from '../components/tenants/tenant-list/tenant-list.component'; +import {TenantDetailComponent} from '../components/tenants/tenant-detail/tenant-detail.component'; +import {DeviceDetailComponent} from '../components/devices/device-detail/device-detail.component'; + +const routes: Routes = [ + { + path: '', + children: [ + {path: 'device-detail/:tenantId/:id', component: DeviceDetailComponent}, + {path: 'tenant-detail/:id', component: TenantDetailComponent}, + {path: 'tenant-list', component: TenantListComponent} + ] + } +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule], + providers: [], + declarations: [] +}) +export class AppRoutingModule { +} diff --git a/device-management-ui/src/app/services/api/api.service.spec.ts b/device-management-ui/src/app/services/api/api.service.spec.ts new file mode 100644 index 00000000..ebaa96ae --- /dev/null +++ b/device-management-ui/src/app/services/api/api.service.spec.ts @@ -0,0 +1,40 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {ApiService} from './api.service'; +import {GoogleService} from "../google/google.service"; + +describe('ApiService', () => { + let service: ApiService; + let googleServiceSpy: { + getIdToken: jasmine.Spy; + } + + beforeEach(() => { + googleServiceSpy = jasmine.createSpyObj('GoogleService', ['getIdToken']); + TestBed.configureTestingModule({ + providers: [ + {provide: GoogleService, useValue: googleServiceSpy} + ] + }); + service = TestBed.inject(ApiService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/services/api/api.service.ts b/device-management-ui/src/app/services/api/api.service.ts new file mode 100644 index 00000000..f72b88a1 --- /dev/null +++ b/device-management-ui/src/app/services/api/api.service.ts @@ -0,0 +1,42 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpHeaders} from "@angular/common/http"; +import {GoogleService} from "../google/google.service"; + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + + constructor(private googleService: GoogleService) { + } + + public getHttpsRequestOptions() { + const idToken = this.googleService.getIdToken(); + const header = new HttpHeaders() + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${idToken}`) + .set('Content-Type', 'application/json') + + return { + headers: header, + withCredentials: true, + observe: 'body' as 'response' + } + } + +} diff --git a/device-management-ui/src/app/services/command/command.service.spec.ts b/device-management-ui/src/app/services/command/command.service.spec.ts new file mode 100644 index 00000000..d4d1d5ca --- /dev/null +++ b/device-management-ui/src/app/services/command/command.service.spec.ts @@ -0,0 +1,61 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {CommandService} from './command.service'; +import {HttpClient} from "@angular/common/http"; +import {ApiService} from "../api/api.service"; +import {of} from "rxjs"; +import {Command} from "../../models/command"; + +describe('CommandService', () => { + let service: CommandService; + let httpClientSpy: { + post: jasmine.Spy; + }; + let apiServiceSpy: { + getHttpsRequestOptions: jasmine.Spy; + }; + + beforeEach(() => { + httpClientSpy = jasmine.createSpyObj('HttpClient', ['post']); + apiServiceSpy = jasmine.createSpyObj('ApiService', ['getHttpsRequestOptions']); + TestBed.configureTestingModule({ + providers: [ + {provide: HttpClient, useValue: httpClientSpy}, + {provide: ApiService, useValue: apiServiceSpy} + ] + }); + service = TestBed.inject(CommandService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return empty body when sendCommand is called', () => { + const request: Command = new Command(); + const result = {}; + httpClientSpy.post.and.returnValue(of(result)); + + service.sendCommand('test-device', 'test-tenant', request).subscribe((success) => { + expect(success).toEqual({}); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.post).toHaveBeenCalled(); + }); +}); diff --git a/device-management-ui/src/app/services/command/command.service.ts b/device-management-ui/src/app/services/command/command.service.ts new file mode 100644 index 00000000..0cd49e36 --- /dev/null +++ b/device-management-ui/src/app/services/command/command.service.ts @@ -0,0 +1,40 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {Observable} from "rxjs"; +import {Command} from "../../models/command"; +import {ApiService} from "../api/api.service"; + +@Injectable({ + providedIn: 'root' +}) +export class CommandService { + + private commandUrlSuffix: string = 'v1/commands/:tenantId/:deviceId' + + constructor(private http: HttpClient, + private apiService: ApiService) { + } + + public sendCommand(deviceId: string, tenantId: string, command: Command): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.commandUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.post(url, command, header); + } +} diff --git a/device-management-ui/src/app/services/config/config.service.spec.ts b/device-management-ui/src/app/services/config/config.service.spec.ts new file mode 100644 index 00000000..d8c8c657 --- /dev/null +++ b/device-management-ui/src/app/services/config/config.service.spec.ts @@ -0,0 +1,74 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {ConfigService} from './config.service'; +import {HttpClient} from "@angular/common/http"; +import {ApiService} from "../api/api.service"; +import {of} from "rxjs"; +import {Config, ConfigRequest} from "../../models/config"; + +describe('ConfigService', () => { + let service: ConfigService; + let httpClientSpy: { + post: jasmine.Spy; + get: jasmine.Spy; + }; + let apiServiceSpy: { + getHttpsRequestOptions: jasmine.Spy; + }; + + beforeEach(() => { + httpClientSpy = jasmine.createSpyObj('HttpClient', ['post', 'get']); + apiServiceSpy = jasmine.createSpyObj('ApiService', ['getHttpsRequestOptions']); + TestBed.configureTestingModule({ + providers: [ + {provide: HttpClient, useValue: httpClientSpy}, + {provide: ApiService, useValue: apiServiceSpy} + ] + }); + service = TestBed.inject(ConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return config when updateConfig is called', () => { + const request: ConfigRequest = new ConfigRequest(); + httpClientSpy.post.and.returnValue(of(request)); + + service.updateConfig('test-device', 'test-tenant', request).subscribe((success) => { + expect(success).toEqual(request); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.post).toHaveBeenCalled(); + }); + + it('should return list of configs when list is called', () => { + const configs : Config[] = [new Config(), new Config()]; + httpClientSpy.get.and.returnValue(of(configs)); + + service.list('test-device', 'test-tenant').subscribe((success) => { + expect(success).toEqual(configs); + expect(success.length).toEqual(2); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.get).toHaveBeenCalled(); + }); +}); diff --git a/device-management-ui/src/app/services/config/config.service.ts b/device-management-ui/src/app/services/config/config.service.ts new file mode 100644 index 00000000..c4004213 --- /dev/null +++ b/device-management-ui/src/app/services/config/config.service.ts @@ -0,0 +1,49 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {Observable} from "rxjs"; +import {ConfigRequest} from "../../models/config"; +import {ApiService} from "../api/api.service"; + +@Injectable({ + providedIn: 'root' +}) +export class ConfigService { + + private configUrlSuffix: string = 'v1/configs/:tenantId/:deviceId'; + + constructor(private http: HttpClient, + private apiService: ApiService) { + } + + public list(deviceId: string, tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.configUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.get(url, header); + } + + public updateConfig(deviceId: string, tenantId: string, config: ConfigRequest): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.configUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.post(url, config, header); + } + +} diff --git a/device-management-ui/src/app/services/credentials/credentials.service.spec.ts b/device-management-ui/src/app/services/credentials/credentials.service.spec.ts new file mode 100644 index 00000000..5f6a8ddd --- /dev/null +++ b/device-management-ui/src/app/services/credentials/credentials.service.spec.ts @@ -0,0 +1,76 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {CredentialsService} from './credentials.service'; +import {HttpClient} from "@angular/common/http"; +import {ApiService} from "../api/api.service"; +import {of} from "rxjs"; +import {Credentials} from "../../models/credentials/credentials"; + +describe('CredentialsService', () => { + let service: CredentialsService; + let httpClientSpy: { + put: jasmine.Spy; + get: jasmine.Spy; + }; + let apiServiceSpy: { + getHttpsRequestOptions: jasmine.Spy; + }; + + beforeEach(() => { + httpClientSpy = jasmine.createSpyObj('HttpClient', ['put', 'get']); + apiServiceSpy = jasmine.createSpyObj('ApiService', ['getHttpsRequestOptions']); + TestBed.configureTestingModule({ + providers: [ + {provide: HttpClient, useValue: httpClientSpy}, + {provide: ApiService, useValue: apiServiceSpy} + ] + }); + service = TestBed.inject(CredentialsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return empty body when save is called', () => { + const request: Credentials[] = [new Credentials()]; + const result = {}; + httpClientSpy.put.and.returnValue(of(result)); + + + service.save('test-device', 'test-tenant', request).subscribe((success) => { + expect(success).toEqual({}); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.put).toHaveBeenCalled(); + }); + + it('should return credentials list when list is called', () => { + const result: Credentials[] = [new Credentials(), new Credentials()]; + httpClientSpy.get.and.returnValue(of(result)); + + service.list('test-device', 'test-tenant').subscribe((success) => { + expect(success).toEqual(result); + expect(success.length).toEqual(2); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.get).toHaveBeenCalled(); + }); +}); diff --git a/device-management-ui/src/app/services/credentials/credentials.service.ts b/device-management-ui/src/app/services/credentials/credentials.service.ts new file mode 100644 index 00000000..cec5936a --- /dev/null +++ b/device-management-ui/src/app/services/credentials/credentials.service.ts @@ -0,0 +1,49 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {Observable} from "rxjs"; +import {Credentials} from "../../models/credentials/credentials"; +import {ApiService} from "../api/api.service"; + +@Injectable({ + providedIn: 'root' +}) +export class CredentialsService { + + private credentialsUrlSuffix: string = '/v1/credentials/:tenantId/:deviceId'; + + constructor(private http: HttpClient, + private apiService: ApiService) { + } + + public save(deviceId: string, tenantId: string, credentials: Credentials[]): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.credentialsUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.put(url, credentials, header); + } + + public list(deviceId: string, tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.credentialsUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.get(url, header); + } + +} diff --git a/device-management-ui/src/app/services/device/device.service.spec.ts b/device-management-ui/src/app/services/device/device.service.spec.ts new file mode 100644 index 00000000..4cec1817 --- /dev/null +++ b/device-management-ui/src/app/services/device/device.service.spec.ts @@ -0,0 +1,164 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {DeviceService} from './device.service'; +import {HttpClient} from "@angular/common/http"; +import {ApiService} from "../api/api.service"; +import {of} from "rxjs"; +import {Device} from "../../models/device"; + +describe('DeviceService', () => { + let service: DeviceService; + let httpClientSpy: { + get: jasmine.Spy; + post: jasmine.Spy; + put: jasmine.Spy; + delete: jasmine.Spy; + }; + let apiServiceSpy: { + getHttpsRequestOptions: jasmine.Spy; + }; + + beforeEach(() => { + httpClientSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put', 'delete']); + apiServiceSpy = jasmine.createSpyObj('ApiService', ['getHttpsRequestOptions']); + TestBed.configureTestingModule({ + providers: [ + {provide: HttpClient, useValue: httpClientSpy}, + {provide: ApiService, useValue: apiServiceSpy} + ] + }); + service = TestBed.inject(DeviceService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return list of devices when listByTenant is called with isGateway = false', () => { + const result: Device[] = [new Device(), new Device(), new Device()]; + apiServiceSpy.getHttpsRequestOptions.and.returnValue({ + headers: {}, + withCredentials: true, + observe: 'body' as 'response' + }); + httpClientSpy.get.and.returnValue(of(result)); + + service.listByTenant('test-tenant', 50, 1, false).subscribe((success) => { + expect(success).toEqual(result); + expect(success.length).toEqual(3); + }, (error) => { + expect(error).toBeFalsy(); + }); + const expectedUrl: string = '/v1/devices/test-tenant/?pageSize=50&pageOffset=1&isGateway=false'; + expect(httpClientSpy.get).toHaveBeenCalledWith(expectedUrl, { + headers: {}, + withCredentials: true, + observe: 'body' as 'response' + }); + }); + + it('should return list of gateways when listByTenant is called with isGateway = true', () => { + const result: Device[] = [new Device(), new Device(), new Device()]; + apiServiceSpy.getHttpsRequestOptions.and.returnValue({ + headers: {}, + withCredentials: true, + observe: 'body' as 'response' + }); + httpClientSpy.get.and.returnValue(of(result)); + + service.listByTenant('test-tenant', 50, 1, true).subscribe((success) => { + expect(success).toEqual(result); + expect(success.length).toEqual(3); + }, (error) => { + expect(error).toBeFalsy(); + }); + const expectedUrl: string = '/v1/devices/test-tenant/?pageSize=50&pageOffset=1&isGateway=true'; + expect(httpClientSpy.get).toHaveBeenCalledWith(expectedUrl, { + headers: {}, + withCredentials: true, + observe: 'body' as 'response' + }); + }); + + it('should return list of all devices when listAll is called', () => { + const result: Device[] = [new Device(), new Device(), new Device()]; + apiServiceSpy.getHttpsRequestOptions.and.returnValue({ + headers: {}, + withCredentials: true, + observe: 'body' as 'response' + }); + httpClientSpy.get.and.returnValue(of(result)); + + service.listAll('test-tenant', 10, 1).subscribe((success) => { + expect(success).toEqual(result); + expect(success.length).toEqual(3); + }, (error) => { + expect(error).toBeFalsy(); + }); + const expectedUrl: string = '/v1/devices/test-tenant/?pageSize=10&pageOffset=1'; + expect(httpClientSpy.get).toHaveBeenCalledWith(expectedUrl, { + headers: {}, + withCredentials: true, + observe: 'body' as 'response' + }); + }); + + it('should return created device when save is called', () => { + const deviceId: string = 'test-device'; + const device: Device = new Device(); + device.id = deviceId; + httpClientSpy.post.and.returnValue(of(device)); + + service.create(device,'test-tenant').subscribe((success) => { + expect(success).toEqual(device); + expect(success.id).toEqual(deviceId); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.post).toHaveBeenCalled(); + }); + + it('should return updated device when update is called', () => { + const deviceId: string = 'test-device'; + const device: Device = new Device(); + device.id = deviceId; + httpClientSpy.put.and.returnValue(of(device)); + + service.update(device,'test-tenant').subscribe((success) => { + expect(success).toEqual(device); + expect(success.id).toEqual(deviceId); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.put).toHaveBeenCalled(); + }); + + it('should return body of true when delete is called', () => { + const deviceId: string = 'test-device'; + const device: Device = new Device(); + device.id = deviceId; + httpClientSpy.delete.and.returnValue(of(true)); + + service.delete(device,'test-tenant').subscribe((success) => { + expect(success).toEqual(true); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.delete).toHaveBeenCalled(); + }); +}); diff --git a/device-management-ui/src/app/services/device/device.service.ts b/device-management-ui/src/app/services/device/device.service.ts new file mode 100644 index 00000000..173e3bb1 --- /dev/null +++ b/device-management-ui/src/app/services/device/device.service.ts @@ -0,0 +1,127 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {Observable} from "rxjs"; +import {ApiService} from "../api/api.service"; +import {Device} from "../../models/device"; + +@Injectable({ + providedIn: 'root' +}) +export class DeviceService { + + private listWithIsGatewayFilterUrlSuffix: string = '/v1/devices/:tenantId/?pageSize=:size&pageOffset=:offset&isGateway=:isGateway'; + private listWithJsonFilterUrlSuffix: string = '/v1/devices/:tenantId/?pageSize=:size&pageOffset=:offset&filterJson=:filter'; + private listWithoutFilterUrlSuffix: string = '/v1/devices/:tenantId/?pageSize=:size&pageOffset=:offset'; + private deviceUrlSuffix: string = '/v1/devices/:tenantId/:deviceId'; + + private isGateway: boolean = false; + + constructor(private http: HttpClient, + private apiService: ApiService) { + } + + public listByTenant(tenantId: string, size: number, offset: number, onlyGateways: boolean): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.listWithIsGatewayFilterUrlSuffix + .replace(':tenantId', tenantId) + .replace(':size', String(size)) + .replace(':offset', String(offset)) + .replace(':isGateway', String(onlyGateways)); + return this.http.get(url, header); + } + + public listBoundDevices(tenantId: string, gatewayId: string, size: number, offset: number): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const filter = this.getBoundDevicesFilter(gatewayId); + const url = this.listWithJsonFilterUrlSuffix + .replace(':tenantId', tenantId) + .replace(':size', String(size)) + .replace(':offset', String(offset)) + .replace(':filter', String(filter)); + return this.http.get(url, header); + } + + public listAll(tenantId: string, size: number, offset: number): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.listWithoutFilterUrlSuffix + .replace(':tenantId', tenantId) + .replace(':size', String(size)) + .replace(':offset', String(offset)) + return this.http.get(url, header); + } + + public getByExactId(tenantId: string, deviceId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.deviceUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.get(url, header); + } + + public create(device: Device, tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.deviceUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', device.id); + const body = device.via ? JSON.stringify({ + "via": device.via + }) : {}; + return this.http.post(url, body, header); + } + + public update(device: Device, tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + let body = {}; + if (device.enabled != null && device.via != null) { + body = JSON.stringify({ + "via": + device.via + , + "enabled": device.enabled + }); + } else if (device.via != null) { + body = JSON.stringify({ + "via": + device.via + }); + } + const url = this.deviceUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', device.id); + return this.http.put(url, body, header); + } + + public delete(device: Device, tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.deviceUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', device.id); + return this.http.delete(url, header); + } + + private getBoundDevicesFilter(gatewayId: string) { + return `{"field": "/via","value": "*\\"${gatewayId}\\"*"}`; + } + + public setActiveTab(isGateway:boolean){ + this.isGateway = isGateway; + } + public getActiveTab() : boolean{ + return this.isGateway; + } +} diff --git a/device-management-ui/src/app/services/google/google.service.ts b/device-management-ui/src/app/services/google/google.service.ts new file mode 100644 index 00000000..59805199 --- /dev/null +++ b/device-management-ui/src/app/services/google/google.service.ts @@ -0,0 +1,60 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {AuthConfig, OAuthService} from "angular-oauth2-oidc"; +import {Router} from "@angular/router"; +import {environment} from "../../../environments/environment"; + +const oAuthConfig: AuthConfig = { + issuer: 'https://accounts.google.com', + strictDiscoveryDocumentValidation: false, + redirectUri: window.location.origin, + clientId: environment.googleClientId, + scope: 'openid profile email' +}; + +@Injectable({ + providedIn: 'root' +}) +export class GoogleService { + + constructor(private oauthService: OAuthService, private router: Router) { + if (!this.oauthService.hasValidAccessToken()) { + this.oauthService.configure(oAuthConfig); + this.oauthService.loadDiscoveryDocument().then(() => { + this.oauthService.tryLoginImplicitFlow().then(() => { + if (!this.oauthService.hasValidAccessToken()) { + console.log('No valid access token, starting implicit flow.'); + this.oauthService.initLoginFlow() + } else { + this.oauthService.loadUserProfile().then((user) => { + this.router.navigate(['/tenant-list']); + }); + } + }, (error) => { + console.log(error); + }); + }, (error) => { + console.log(error); + }); + } + } + + public getIdToken() { + return this.oauthService.getIdToken(); + } + +} diff --git a/device-management-ui/src/app/services/loading-spinner/loading-spinner.service.spec.ts b/device-management-ui/src/app/services/loading-spinner/loading-spinner.service.spec.ts new file mode 100644 index 00000000..aeaf34c1 --- /dev/null +++ b/device-management-ui/src/app/services/loading-spinner/loading-spinner.service.spec.ts @@ -0,0 +1,31 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {LoadingSpinnerService} from './loading-spinner.service'; + +describe('LoadingSpinnerService', () => { + let service: LoadingSpinnerService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(LoadingSpinnerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/services/loading-spinner/loading-spinner.service.ts b/device-management-ui/src/app/services/loading-spinner/loading-spinner.service.ts new file mode 100644 index 00000000..50458229 --- /dev/null +++ b/device-management-ui/src/app/services/loading-spinner/loading-spinner.service.ts @@ -0,0 +1,33 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {Subject} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class LoadingSpinnerService { + + public isLoading = new Subject(); + + public show() { + this.isLoading.next(true); + } + + public hide() { + this.isLoading.next(false); + } +} diff --git a/device-management-ui/src/app/services/notification/notification.service.spec.ts b/device-management-ui/src/app/services/notification/notification.service.spec.ts new file mode 100644 index 00000000..a59aed8e --- /dev/null +++ b/device-management-ui/src/app/services/notification/notification.service.spec.ts @@ -0,0 +1,31 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {NotificationService} from './notification.service'; + +describe('NotificationService', () => { + let service: NotificationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(NotificationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/services/notification/notification.service.ts b/device-management-ui/src/app/services/notification/notification.service.ts new file mode 100644 index 00000000..53951a3f --- /dev/null +++ b/device-management-ui/src/app/services/notification/notification.service.ts @@ -0,0 +1,45 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable, TemplateRef} from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) + +export class NotificationService { + + public toasts: any[] = []; + + private show(textOrTpl: string | TemplateRef, header: string, options: any = {}) { + this.toasts.push({textOrTpl, header, ...options}); + } + + public success(message: string) { + this.show(message, 'Success', {classname: 'bg-success text-light', autohide: true}); + } + + public warning(message: string) { + this.show(message, 'Warning', {classname: 'bg-warning text-light', autohide: true}); + } + + public error(message: string) { + this.show(message, 'Error', {classname: 'bg-danger text-light', autohide: false}); + } + + public remove(toast: any) { + this.toasts = this.toasts.filter(t => t !== toast); + } +} diff --git a/device-management-ui/src/app/services/sortable-table/sortable-table.directive.spec.ts b/device-management-ui/src/app/services/sortable-table/sortable-table.directive.spec.ts new file mode 100644 index 00000000..db23e3a6 --- /dev/null +++ b/device-management-ui/src/app/services/sortable-table/sortable-table.directive.spec.ts @@ -0,0 +1,23 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {SortableTableDirective} from './sortable-table.directive'; + +describe('SortableTableDirective', () => { + it('should create an instance', () => { + const directive = new SortableTableDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/services/sortable-table/sortable-table.directive.ts b/device-management-ui/src/app/services/sortable-table/sortable-table.directive.ts new file mode 100644 index 00000000..5c684451 --- /dev/null +++ b/device-management-ui/src/app/services/sortable-table/sortable-table.directive.ts @@ -0,0 +1,52 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Directive, EventEmitter, Input, Output} from '@angular/core'; + +type SortDirection = 'asc' | 'desc' | ''; +const rotate: { [key: string]: SortDirection } = {'asc': 'desc', 'desc': '', '': 'asc'}; + +export interface SortEvent { + column: string; + direction: SortDirection; +} + +@Directive({ + selector: 'th[sortable]', + host: { + '[class.sort-asc]': 'direction === "asc"', + '[class.sort-desc]': 'direction === "desc"', + '(click)': 'rotate()' + } +}) +export class SortableTableDirective { + + @Input() + public sortable: string = ''; + + @Input() + public direction: SortDirection = ''; + + @Output() + public sort = new EventEmitter(); + + constructor() { + } + + rotate() { + this.direction = rotate[this.direction]; + this.sort.emit({column: this.sortable, direction: this.direction}); + } +} diff --git a/device-management-ui/src/app/services/sortable-table/sortable-table.service.spec.ts b/device-management-ui/src/app/services/sortable-table/sortable-table.service.spec.ts new file mode 100644 index 00000000..f05e644d --- /dev/null +++ b/device-management-ui/src/app/services/sortable-table/sortable-table.service.spec.ts @@ -0,0 +1,99 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {SortableTableService} from './sortable-table.service'; +import {QueryList} from "@angular/core"; +import {SortableTableDirective} from "./sortable-table.directive"; + +describe('SortableTableService', () => { + let service: SortableTableService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SortableTableService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return queryList when resetHeaders is called', () => { + const sortableHeaders: QueryList = getSortableHeaders(); + const result = service.resetHeaders(sortableHeaders, 'thirdHeader'); + expect(result.length).toEqual(3); + expect(result.get(0)?.direction).toEqual(''); + expect(result.get(1)?.direction).toEqual(''); + expect(result.get(2)?.direction).toEqual('asc'); + }); + + it('should return passed array when array is empty', () => { + const result = service.sortItems([], {column:'col', direction:'asc'}); + expect(result.length).toEqual(0); + }); + + it('should return passed array when column is empty', () => { + const items: any[] = getItems(); + const result = service.sortItems(items, {column:'', direction:'asc'}); + expect(result.length).toEqual(3); + expect(result).toEqual(items); + }); + + it('should return sorted array in descending order when sortItems is called', () => { + const items: any[] = getItems(); + const result = service.sortItems(items, {column:'thirdCol', direction:'desc'}); + expect(result[0].firstCol).toEqual('third row data'); + expect(result[1].firstCol).toEqual('second row data'); + expect(result[2].firstCol).toEqual('first row data'); + }); + + it('should return sorted array in ascending order when sortItems is called', () => { + const items: any[] = getItems(); + const result = service.sortItems(items, {column:'status.report', direction:'asc'}); + expect(result[0].status.report).toEqual('error'); + expect(result[1].status.report).toEqual('success'); + expect(result[2].status.report).toEqual('warning'); + }); + +}); + +function getSortableHeaders(): QueryList { + const sortableHeaders: QueryList = new QueryList(); + + const firstHeader: SortableTableDirective = new SortableTableDirective(); + firstHeader.sortable = 'firstHeader'; + firstHeader.direction = 'asc'; + + const secondHeader: SortableTableDirective = new SortableTableDirective(); + secondHeader.sortable = 'secondHeader'; + secondHeader.direction = 'desc'; + + const thirdHeader: SortableTableDirective = new SortableTableDirective(); + thirdHeader.sortable = 'thirdHeader'; + thirdHeader.direction = 'asc'; + + const headers = [firstHeader, secondHeader, thirdHeader]; + sortableHeaders.reset(headers); + return sortableHeaders; +} + +function getItems(): any[] { + return [ + {firstCol: 'first row data', secondCol: 'first row data', thirdCol: 'first row data', status: {report: 'success'}}, + {firstCol: 'second row data', secondCol: 'second row data', thirdCol: 'second row data', status: {report: 'warning'}}, + {firstCol: 'third row data', secondCol: 'third row data', thirdCol: 'third row data', status: {report: 'error'}}, + ]; +} diff --git a/device-management-ui/src/app/services/sortable-table/sortable-table.service.ts b/device-management-ui/src/app/services/sortable-table/sortable-table.service.ts new file mode 100644 index 00000000..010b1e9b --- /dev/null +++ b/device-management-ui/src/app/services/sortable-table/sortable-table.service.ts @@ -0,0 +1,81 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable, QueryList} from '@angular/core'; +import {SortableTableDirective, SortEvent} from "./sortable-table.directive"; + +@Injectable({ + providedIn: 'root' +}) +export class SortableTableService { + + public resetHeaders(sortableHeaders: QueryList, column: string): QueryList { + sortableHeaders.forEach(header => { + if (header.sortable !== column) { + header.direction = ''; + } + }); + return sortableHeaders; + } + + public sortItems(items: T[],{ column, direction }: SortEvent): T[] { + if (!items || items.length === 0) { + return items; + } + + if (direction === '' || column === '') { + return items; + } + const dotIndex = column.indexOf('.'); + if (dotIndex < 0) { + return items.sort((i1: T, i2: T) => { + // @ts-ignore + const res = this.compare(i1[column], i2[column]); + return direction === 'asc' ? res : -res; + }); + } else { + const firstProperty = column.substring(0, dotIndex); + const secondProperty = column.substring(dotIndex + 1, column.length); + return items.sort((i1: T, i2: T) => { + // @ts-ignore + const res = this.compare(i1[firstProperty][secondProperty], i2[firstProperty][secondProperty]); + return direction === 'asc' ? res : -res; + }); + } + + } + + private compare(v1: any, v2: any) { + const normalizedV1 = this.normalize(v1); + const normalizedV2 = this.normalize(v2); + if (normalizedV1 < normalizedV2) { + return -1; + } else if (normalizedV1 > normalizedV2) { + return 1; + } else { + return 0; + } + } + + private normalize(value: number | string): number | string { + if (!value) { + return ''; + } + if (!isNaN(Number(value.toString()))) { + return value; + } + return (value + '').toLowerCase(); + } +} diff --git a/device-management-ui/src/app/services/states/states.service.spec.ts b/device-management-ui/src/app/services/states/states.service.spec.ts new file mode 100644 index 00000000..c934d879 --- /dev/null +++ b/device-management-ui/src/app/services/states/states.service.spec.ts @@ -0,0 +1,61 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {StatesService} from './states.service'; +import {HttpClient} from "@angular/common/http"; +import {ApiService} from "../api/api.service"; +import {of} from "rxjs"; +import {State} from "../../models/state"; + +describe('StatesService', () => { + let service: StatesService; + let httpClientSpy: { + get: jasmine.Spy; + }; + let apiServiceSpy: { + getHttpsRequestOptions: jasmine.Spy; + }; + + beforeEach(() => { + httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); + apiServiceSpy = jasmine.createSpyObj('ApiService', ['getHttpsRequestOptions']); + TestBed.configureTestingModule({ + providers: [ + {provide: HttpClient, useValue: httpClientSpy}, + {provide: ApiService, useValue: apiServiceSpy} + ] + }); + service = TestBed.inject(StatesService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return list of two states when list is called', () => { + const result: State[] = [new State(), new State()]; + httpClientSpy.get.and.returnValue(of(result)); + + service.list('test-device', 'test-tenant').subscribe((success) => { + expect(success).toEqual(result); + expect(success.length).toEqual(2); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.get).toHaveBeenCalled(); + }); +}); diff --git a/device-management-ui/src/app/services/states/states.service.ts b/device-management-ui/src/app/services/states/states.service.ts new file mode 100644 index 00000000..2b7ad338 --- /dev/null +++ b/device-management-ui/src/app/services/states/states.service.ts @@ -0,0 +1,39 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {Observable} from "rxjs"; +import {ApiService} from "../api/api.service"; + +@Injectable({ + providedIn: 'root' +}) +export class StatesService { + + private statesUrlSuffix: string = 'v1/states/:tenantId/:deviceId'; + + constructor(private http: HttpClient, + private apiService: ApiService) { + } + + public list(deviceId: string, tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.statesUrlSuffix + .replace(':tenantId', tenantId) + .replace(':deviceId', deviceId); + return this.http.get(url, header); + } +} diff --git a/device-management-ui/src/app/services/tenant/tenant.service.spec.ts b/device-management-ui/src/app/services/tenant/tenant.service.spec.ts new file mode 100644 index 00000000..7fb66c23 --- /dev/null +++ b/device-management-ui/src/app/services/tenant/tenant.service.spec.ts @@ -0,0 +1,105 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TestBed} from '@angular/core/testing'; + +import {TenantService} from './tenant.service'; +import {HttpClient} from "@angular/common/http"; +import {ApiService} from "../api/api.service"; +import {of} from "rxjs"; +import {Tenant} from "../../models/tenant"; + +describe('TenantService', () => { + let service: TenantService; + let httpClientSpy: { + get: jasmine.Spy; + post: jasmine.Spy; + put: jasmine.Spy; + delete: jasmine.Spy; + }; + let apiServiceSpy: { + getHttpsRequestOptions: jasmine.Spy; + }; + + beforeEach(() => { + httpClientSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put', 'delete']); + apiServiceSpy = jasmine.createSpyObj('ApiService', ['getHttpsRequestOptions']); + TestBed.configureTestingModule({ + providers: [ + {provide: HttpClient, useValue: httpClientSpy}, + {provide: ApiService, useValue: apiServiceSpy} + ] + }); + service = TestBed.inject(TenantService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return list of 4 tenants when list is called', () => { + const result: Tenant[] = [new Tenant(), new Tenant(), new Tenant(), new Tenant()]; + httpClientSpy.get.and.returnValue(of(result)); + + service.list(50, 1).subscribe((success) => { + expect(success).toEqual(result); + expect(success.length).toEqual(4); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.get).toHaveBeenCalled(); + }); + + it('should return created tenant when create is called', () => { + const request: Tenant = new Tenant(); + request.id = 'test-tenant'; + request.ext = {'messaging-type': 'pubsub'}; + httpClientSpy.post.and.returnValue(of(request)); + + service.create(request).subscribe((success) => { + expect(success).toEqual(request); + expect(success.ext['messaging-type']).toEqual('pubsub'); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.post).toHaveBeenCalled(); + }); + + it('should return updated tenant when update is called', () => { + const request: Tenant = new Tenant(); + request.id = 'test-tenant'; + request.ext = {'messaging-type': 'kafka'}; + httpClientSpy.put.and.returnValue(of(request)); + + service.update(request).subscribe((success) => { + expect(success).toEqual(request); + expect(success.ext['messaging-type']).toEqual('kafka'); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.put).toHaveBeenCalled(); + }); + + it('should body of true when delete is called', () => { + httpClientSpy.delete.and.returnValue(of(true)); + + service.delete('test-tenant').subscribe((success) => { + expect(success).toEqual(true); + }, (error) => { + expect(error).toBeFalsy(); + }); + expect(httpClientSpy.delete).toHaveBeenCalled(); + }); +}); diff --git a/device-management-ui/src/app/services/tenant/tenant.service.ts b/device-management-ui/src/app/services/tenant/tenant.service.ts new file mode 100644 index 00000000..b9f616c1 --- /dev/null +++ b/device-management-ui/src/app/services/tenant/tenant.service.ts @@ -0,0 +1,70 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {Observable} from "rxjs"; +import {HttpClient} from "@angular/common/http"; +import {Tenant} from "../../models/tenant"; +import {ApiService} from "../api/api.service"; + +@Injectable({ + providedIn: 'root' +}) + +export class TenantService { + private listTenantUrlSuffix: string = '/v1/tenants/?pageSize=:size&pageOffset=:offset'; + private tenantUrlSuffix: string = '/v1/tenants/:tenantId'; + + constructor(private http: HttpClient, + private apiService: ApiService) { + } + + public list(size: number, offset: number): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.listTenantUrlSuffix + .replace(':size', String(size)) + .replace(':offset', String(offset)); + return this.http.get(url, header); + } + + public create(tenant: Tenant): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.tenantUrlSuffix.replace(':tenantId', tenant.id); + const requestBody = this.getTenantRequestBody(tenant); + return this.http.post(url, requestBody, header); + } + + public update(tenant: Tenant): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.tenantUrlSuffix.replace(':tenantId', tenant.id); + const requestBody = this.getTenantRequestBody(tenant); + return this.http.put(url, requestBody, header); + } + + public delete(tenantId: string): Observable { + const header = this.apiService.getHttpsRequestOptions(); + const url = this.tenantUrlSuffix.replace(':tenantId', tenantId); + return this.http.delete(url, header); + } + + private getTenantRequestBody(tenant: Tenant) { + return { + "ext": { + "messaging-type": tenant.ext["messaging-type"] + } + } + } + +} diff --git a/device-management-ui/src/app/shared/loader.interceptor.spec.ts b/device-management-ui/src/app/shared/loader.interceptor.spec.ts new file mode 100644 index 00000000..bd9760c0 --- /dev/null +++ b/device-management-ui/src/app/shared/loader.interceptor.spec.ts @@ -0,0 +1,31 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import { TestBed } from '@angular/core/testing'; + +import { LoaderInterceptor } from './loader.interceptor'; + +describe('LoaderInterceptor', () => { + beforeEach(() => TestBed.configureTestingModule({ + providers: [ + LoaderInterceptor + ] + })); + + it('should be created', () => { + const interceptor: LoaderInterceptor = TestBed.inject(LoaderInterceptor); + expect(interceptor).toBeTruthy(); + }); +}); diff --git a/device-management-ui/src/app/shared/loader.interceptor.ts b/device-management-ui/src/app/shared/loader.interceptor.ts new file mode 100644 index 00000000..34ab11d2 --- /dev/null +++ b/device-management-ui/src/app/shared/loader.interceptor.ts @@ -0,0 +1,37 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Injectable} from '@angular/core'; +import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {finalize} from 'rxjs/operators'; +import {LoadingSpinnerService} from '../services/loading-spinner/loading-spinner.service'; + +@Injectable() +export class LoaderInterceptor implements HttpInterceptor { + + constructor(public loaderService: LoadingSpinnerService) { + } + + public intercept(req: HttpRequest, next: HttpHandler): Observable> { + this.loaderService.show(); + return next.handle(req).pipe( + finalize(() => { + this.loaderService.hide(); + }) + ); + } + +} diff --git a/device-management-ui/src/app/shared/search-filter.pipe.spec.ts b/device-management-ui/src/app/shared/search-filter.pipe.spec.ts new file mode 100644 index 00000000..9a204288 --- /dev/null +++ b/device-management-ui/src/app/shared/search-filter.pipe.spec.ts @@ -0,0 +1,59 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {SearchFilterPipe} from './search-filter.pipe'; + +describe('SearchFilterPipe', () => { + it('create an instance', () => { + const pipe = new SearchFilterPipe(); + expect(pipe).toBeTruthy(); + }); + + it('should return passed array when array is empty', () => { + const searchText: string = 'test-tenant'; + const items: any[] = []; + const pipe = new SearchFilterPipe(); + const result = pipe.transform(items, searchText); + expect(result).toEqual(items); + expect(result?.length).toEqual(0); + }); + + it('should return passed array when searchText is empty', () => { + const searchText: string = ''; + const items: any[] = getItems(); + const pipe = new SearchFilterPipe(); + const result = pipe.transform(items, searchText); + expect(result).toEqual(items); + expect(result?.length).toEqual(3); + }); + + it('should return one item when searchText matches', () => { + const searchText: string = 'second'; + const items: any[] = getItems(); + const pipe = new SearchFilterPipe(); + const result = pipe.transform(items, searchText); + // @ts-ignore + expect(result[0].name).toEqual('second test name'); + expect(result?.length).toEqual(1); + }); +}); + +function getItems(): any[] { + return [ + {id: 'first id', name: 'first test name'}, + {id: 'second id', name: 'second test name'}, + {id: 'third id', name: 'third test name'}, + ]; +} diff --git a/device-management-ui/src/app/shared/search-filter.pipe.ts b/device-management-ui/src/app/shared/search-filter.pipe.ts new file mode 100644 index 00000000..5f33a115 --- /dev/null +++ b/device-management-ui/src/app/shared/search-filter.pipe.ts @@ -0,0 +1,34 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Pipe, PipeTransform} from '@angular/core'; + +@Pipe({ + name: 'searchFilter', +}) +export class SearchFilterPipe implements PipeTransform { + + transform(items: any [] | null, searchText: string): any [] | null { + if (!items) { + return null; + } + if (!searchText) { + return items; + } + return items.filter(item => { + return (item.id.toLowerCase().includes(searchText.toLowerCase())); + }); + } +} diff --git a/device-management-ui/src/app/shared/truncate.pipe.spec.ts b/device-management-ui/src/app/shared/truncate.pipe.spec.ts new file mode 100644 index 00000000..c1642175 --- /dev/null +++ b/device-management-ui/src/app/shared/truncate.pipe.spec.ts @@ -0,0 +1,45 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {TruncatePipe} from './truncate.pipe'; + +describe('TruncatePipe', () => { + + it('create an instance', () => { + const pipe = new TruncatePipe(); + expect(pipe).toBeTruthy(); + }); + + it('transform - should not truncate and return text', () => { + const text = 'test'; + const pipe = new TruncatePipe(); + const result = pipe.transform(text); + expect(result).toEqual(text); + }); + + it('transform - should truncate for length of 4 and return truncated text', () => { + const text = 'textShouldBeTruncated'; + const pipe = new TruncatePipe(); + const result = pipe.transform(text, 4); + expect(result).toEqual('text...'); + }); + + it('transform - should truncate for length of 4 and suffix and return truncated text', () => { + const text = 'textShouldBeTruncated'; + const pipe = new TruncatePipe(); + const result = pipe.transform(text, 4, '*'); + expect(result).toEqual('text*'); + }); +}); diff --git a/device-management-ui/src/app/shared/truncate.pipe.ts b/device-management-ui/src/app/shared/truncate.pipe.ts new file mode 100644 index 00000000..8b0cc8f9 --- /dev/null +++ b/device-management-ui/src/app/shared/truncate.pipe.ts @@ -0,0 +1,30 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Pipe, PipeTransform} from '@angular/core'; + +@Pipe({ + name: 'truncate' +}) +export class TruncatePipe implements PipeTransform { + + transform(text: string, length: number = 50, suffix: string = '...'): string { + if (text.length > length) { + return text.substring(0, length).trim() + suffix; + } + return text; + } + +} diff --git a/device-management-ui/src/assets/_variables.scss b/device-management-ui/src/assets/_variables.scss new file mode 100644 index 00000000..f2a331d2 --- /dev/null +++ b/device-management-ui/src/assets/_variables.scss @@ -0,0 +1,17 @@ +$primary: #f08712 !default; +$secondary: #2b2b2b !default; + +:root { + --theme: #f08712; + --theme-light: #f087124d; + --secondary: #2b2b2b; + --danger: #D1423F; + --warning: #EF8600; + --success: #51B551; + --backgroundlight: #f3f4f6; + --font: #212529 +} + +$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; + +$navbar-nav-link-padding-x: 1rem !default; diff --git a/device-management-ui/src/assets/env.js b/device-management-ui/src/assets/env.js new file mode 100644 index 00000000..1856172b --- /dev/null +++ b/device-management-ui/src/assets/env.js @@ -0,0 +1,4 @@ +(function (window) { + window["env"] = window["env"] || {}; + window["env"].GOOGLE_CLIENT_ID = ''; +}(this)); diff --git a/device-management-ui/src/assets/env.template.js b/device-management-ui/src/assets/env.template.js new file mode 100644 index 00000000..9a274c9c --- /dev/null +++ b/device-management-ui/src/assets/env.template.js @@ -0,0 +1,4 @@ +(function (window) { + window["env"] = window["env"] || {}; + window["env"].GOOGLE_CLIENT_ID = "${ENV_GOOGLE_CLIENT_ID}"; +})(this) diff --git a/device-management-ui/src/assets/images/favicon.ico b/device-management-ui/src/assets/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..924daee645610c24f04dc50af3d1436b118935c4 GIT binary patch literal 15086 zcmeI3dz_b39>+URW=@)|#Xei=b43uww zy<*9ae|Jm^?0HA|uHY~*7z_nNz#z~M>;-anOwAn!-Hrh-fQ_I5G`pxL{T6%zo(30# z0|7m?ZGPefWV;S-+Kx0QQ|{3A2g`uT08wa4!4u$g&?!?n9-Yd7W5EdU5O@*H0dIjf zz)Ua^j0S%K$ALXTi#m~~x<3gr(LG30O}Sa%_n@PvU$PXMUBKC3HYf*b;~>xAyJ#xF zLU1!U(9fUupMn)2SkAt8A;0tO<)#ZrMnirrm<|Sj7B-SR^4o(VPz6%)O80Fk< zxqOf3`MWTl(z(Z&3Ul+W|W<^$}0+q^n6?X!CG>4vE3O`E80LHdyQCFJezw(*T2-xGN3 zeP_A3d}ghgwW85H`$@eyVSH7(w$_+F+2ubn$X~WN^{FcJ2|B9{O22P1o9mj*)Fln( zD8_^87c3Ka*F1YZ(74r{n3P`jP~Tj&xyiipMT1$eHrCer-@}+oUhYeH;yKy5;ZUA6 z_7Y^M?D3CRnX6u{HD~|3+Wg^#8e5;`WliSd=`}_+Q$2&_eD8ne`2_I2FE4@Kv%{Eo zBDQ@;yrN7E%{T{AN#eWh!+qwE{4ba+}z+3OqZx8Gv8wq_Msx(jiuili^G$W_p z9P&WY{?thM;h;s|6kqAxmGqPeRpuG`%ckmPvk|LEF`X1@Myp5$CD$YS)4E@5er@ zH|winJj8G4v>IENvF}Gr|3^~VejGfl?S1;o_mKY+mzC^U@@2ncAFDJou+s+Yb~|JI zfO{)!Td5whT~F2yt1s_L1+69H&~I%;lUahDubowE*A}mRB~#@^n?(MbUFqdA9hatC#J@9ReM&R31*){;7N(6|cQ@07oqd0++Y zcJti2Sl@$(zunTvmJe~5UuXX_-*Um7X?P^mL2V&ho%VEBeaTFJ_TX?Li0376~9$y*3hr- zez(qyo*%;)o|UI>s$*+-LaX^?4A{%3UPh0U(VDOMBO{s8U+ZUVUX9o_TkE!b59M9) zpISS%Gvx=}VxaXeVV=oId6$T?@~stLYyh6GuJ)Agp}LI+*Ms5o<*A20P-zyfjoL9f zWl6pD{j{cj^i9+p_Mn{)JszoSqWR-~kl^RHMsGhYd=$&|1($$p!4PmH=nSl{PudgA z(s(Rwv5)1@w7MuF(5`0EZ4)_I_ z53Eg9hkaPz;OtN*|5-p2lQPkI(i3e~#v~fc=TE6N>nfY9|L6H=(0lb%Us9X(-yXV4CbEwL zvZbd}JXL@B^~5Uc+nmn2=IcLk8n>L*(_Y)74#-tJt8o|9$@)f$Kfb~K=T5FpH*d-p zv$1$K>w!$g=`D0up2o2JNBIlG7zgT4$!N*{9PFD--6ac|mnG9l{+MEa`Dxd@mUi8$ zg3rM1p#Pc3)jnej+sdcQC-z&kK5G4j-BbGwS_=kk2mR$Eh5Fj+KLdyjWdGNR$F&Be z@R94_lMqW~qb|to3O)u2_LrUq%Kn*rX~kWFo|PA*bKkG@_-&6A9ma;~rM|9sUP#jKRi8~DZA`c= z>xF#zyPb=a2rrQ?l!?GH{)p<^=Lqm;QM9sg{6M~_oLXd9%cm*(D| zWJAAnGSEXY+M_9S4Yq-7KQbNPY<@?dgMs$UgSv*j&xNkldkWjSKIn87C`(fZ#kqys zT9>Wik6t^0VIWx@G+$n{HN3M`#;wC3(zk)mkrd-7od;s zwuaA-Wp-=}82fXaU|O1|9LFii;V)^iQxtLXmFAZ_oK6=zd8J%SB5hy71tZd2H_fpr ze~OjwOC8ad${bz_pK`tDxhec3dM|XGsL~?GX(q`r=m)1CEk z3XOet>~|QnDy6KY7moxKyYIy@ImTK>`o!d$Qa3GcA1fEZg5nX)kEJnt>aYJ@_p}PY zNg#ett1~Y<=#P0oao;iEG;jhq2&BC`(iYW0&TqkgK?R6_YOoGyuDuoLJUt_S6guty z^#TJxAy8fVgM84UKI9z=mV+>Vz{}IQ=F33qa0?!?0r}m)H9$IREv^JwA5<6lfTcj+ zMu8)MT`x$l0P>qWSvH#^45oBVW=t36(w zC0{T#cK+)B?mmA(db-#4JYO3^V`B`()=A=|^_0>2GX$v3ZM}7lt$l^(KCL&eapqq@ zoRuWLReatXbOa?T?}y@>1MaIZ+9yqMN9ShBsGqM?S+9&_YoB8&XZ2UJPu88gA4zn$ zfpSNK1Rq~~wAcU8f{2;N{geqG*PD<#3u^PLKrR>$tp2i<&SZ4B1WKYwMZ(>jPoX2mY)s#Kl?Ylaoukv(IUA4Cl#s0Kp>ReKHA9T-X1ZQOW=J6+$?Ssw~OTO(2{s}Z^ z1^G0?yRrAUil+IT|LZP`&a!55CigJ?T(U#TczIv*Z3GCNI|Fu5+cN^U^5zAXnW*0k#sMTX^ZV$wi*lXgd#w z$d*sqt8FBObAkWvU;@yZx(mZgwQVkP_6GL>t+|RxRPUjH9f0iokktY;Xr`Y?C?Sm3_R)kW=Y0th&9w?0VV>_dq zEsGMiBTCqs&~`--+PWy^o1pCrZCyn2*}5n+-cS1!+LkB)@qLP&c?tZmujXL+0g89_ z2kinRDPN%<239anma$%Ij?f&bb#*rtB$bcV7hDJK2kKvnD;3A9t>?<`(V4koIyatB zd_4*D04ghg(fYum)icmqq%k{DbF}VlO#7_C#tV9P=P$`C0(KpdPxDt|7T-PRxqKT8 zZH4?U-C@z$<$2sCa%02gtUt?pjNyLHq$(TtC{9uQi5?Nk{ta{llO<37lKdCq7kkH+ z_^NsM%$nvaxsNWMidS_<7<$d+kAOW~8>zo91p3y5%u4R)HayS2AyCY!7*D)acjPE0 zy#=rfx8=wCfnX%Kgz_VZqc$qm$1aVOTSWc`KsG%Eg!F_MFTQCGm@nV!kKC_z{m`}K zU7o6(rwKIuz-E;{mpbd-w(CbOBR}mOGH6tfHL9z8pvz{|*m{$v7_PS`Cs_(j2k@%; zk^Vi2;PxqrYK|-ZVq`MoG7=eL7Ksuz696?eg6hx#I7`SyeI>EYh;ku*L9<^TI<5U-+)ue?!6E8+kNlWKZ*U4LR{EN<|n zCS2L&2`a&5LG1SkMh^sZ)s>KKWEJMv;%s6!>xq~5&?a;|C9M8lN!QC z-Lfb(oR+@gE2&FmdsRvN6Vy-HQyO=wlB&C2lT4w~N<3-eoS%_}?Y;LZr|;ctB^t=f zJZ+g2G$JQKS^cIE+buyj9i5AE2 z`6HmF)J_^RBcDU9Q7;iyDP65IRwG_i6Qy_Xnecc%`&9B@Yj>|oslCn37V?L|_{Qo` zKBw2RHmU}>bkaIIe}gAY^TtyI6i0e!k57lkV*@O@)*waqiquG){RSD zLpsRa+6=h1c`FfNP^zJ#d;Sa6;3R%S%^bE}@;BHPYuFqS4W|=e$&0Y){%2%S!siT;M1EINQHx+@g zXgJ05%mi&WhvccRn9zk*9MPL+2GETbgJg0cEG)~k*BoDJLQ06Ao3$1@G_?#1maCuq zb+IYJWOXuQBv>rjzUiB3g*r)*t(zxLld;Z$C0@ttnGvt6*rIpy*d`}P)_dl)h!HY? zcA?RWmP|+qyOb^A{p$qDA>cUg<=H-zAensCi65MCdgt~lfd#K8vqJ@cxSA%^z<|;} z7QHBPx)Ouj*qp7@_aNcDx=9V!@6_E5%>GS}kjFJi&j2UDa!LdW>AU*s+wl6AkV&CaNwn z2thR@H+RKdxTo14y{DdWcH`%;fx&9EsXwhi-gJBNL)xTnlmrq(5*3395a)dN@!g|D zb>Wv`Zs&K&EY2%Ru&~P2d4bDqb}GBlC{0`US9E7xFVUP_7p`|!VLXZ2*;|7(D-xFi zIS;kEJXz*(-}7^XZ>k^7u=au_Fub&Rzc1R#?Q`sH;Gp%~(~a$9JxCLcouOqj z&6`!bB-QWk*0Z!xN7=q%&tO*MaF9l6gN0S?@-nQ)%y|;Nf~9~!Jri$9eCze8sBJ)W z$nN9S1>S?Dtxa+s2&Mp2#w#D>)#KF$*I@?J^0nF}evc)>NYNua61lO!*==9!NHa&e zMKD+U1KZ=9ig3n|D*`hoo`|>rJ*`Hk6gs%RuOOC#{f`%R`RfkDBWt8WiL}#kuf9(E z(jTr?HQ?e8Qh(xh)@&pMu?y@YD@2edw4aK6L`RMFL#pkMd!1?SMmJzWe}e}f7l^hM z-YqbB6{gna%*T@$Ul?m$_P^&0hZG~&Rsgk@Z}6z4UFPlHw{nv-&x zj8*i|S_Ax~ zbny6kdPL$tOiFO|VJO0~hDML|`AYcQCtV-rdS`h<`OPP&E~V~OR>-i< z3oy1?rh837_Jz81`PMqAcU5Bszni<}iV#7v^llEU%vrX{E$}bFbI1YngTQK&^Y_aX zB7|RGpVUWKzVFpAfY!#MIpZ2IIIXo_@76N4c|s0dW` ztd}H;=(`LFZ(P^!5Zk;A9jaFK`N(a^xyB&Bc_SZ?>-nS{?0|h zOKueVp#c*SW$^^%&qJXTm2+0|_t2SAR0!L!xX#>?XTOG7^khk==I8=PL|M66bKZmT z3>5J1{E3xKHZ297kyLYJFCys?HnHR0B05ixzMcJI2hT1Ny;x#p0Hthf!?8K`CxXxg zb;4-fi|O)#rm>P!czGP$tiD26=|q0R!W;%U3h%nH z?k~yxK{f!buFWfT^wT4hOXj@vVI;gcqrWNO@n_&RsQ@&2m64l6&UW#5cs*uK7M(F1 z7FB__vg&_#0<32y;dOgPX%alNs+h~cowEr%-!sNu9RGV!mY+Lo(+cHZ#K9f^;U1RZ za?7K+?`M)Zm~{S~z6DWeQ4ZT0KUmEElEKv`4MBZR(EWRTjS4Ne-3&Li$MgJ0H+z<; z&O#q4NC4lOP{H+0rnKm||A1l*YCwSW?(H}Uh&A|+46n!fRI;lQqpJ#C_sYbJU#?36 z6~+T#*7tkz>6;`xnJ82jdPs3gus9IFB=jP3u^OX$CnwJ!)2nudptaj%J_ojz`V7)c z4ew%u_|}HtrsS{YZfH@R9&k1>*q*$D&05|xzHGxK_I$<2)5iM7aE3+-Sl9<$6mv| z-{X&Vw~|2tNR4mm%ZRL&=gq}ndxEBSYkTs(85psGX>*(L1jsd%ve~jrTr~#snIY2Hf~mb!`Kf7 z_SbV|EMMte%-ALOCtdk|->t=?f&5ujY~RJtA9-?z6h&dWB6js#(_t%fPgu@h5F4m^ z1xr8UX+?g$W`*cHU` zx|v{TMZyLwc;uY-e_IU=xK5mxJey)bK4m zE6<&#M|@;Nm7bwm0W9Bzb*8PzCPLn7xLyhLJ@W-%nC`vXZ)}UI-`kh67+z z4DuUtbw@sMd<^Wg9%%U%YmgKL<5PEYA6_IOFb)-`G)=G}iq3XYPrGcyRPupQ0A4ZV zt}&XgdGm>r&`Xvp?1e)1v1^^$!Ew%;3dE&}nD|~HEXj(LcGzhd^y2+b@(!j@Zi~%Z zGP`xH6T0^8r=H?|J6dy#fj-0CUye^c)=VO?+^>F@# zt~L1URU@OT65}(zXrfkLLv(H&=FTiq|E2*p;1p3&?5b3i^7uvj)tEhZtjVQIU9JNP zl=C3lTL*Tr_QfSBqbjFl?7<87b0T$Fsq|7tMMq3%x6fl;_x(;^z+5`_RK?y?o^#Nk zjX@EWCuZzRiMrSd`fy_2UPr;-rQMvS+P+pKpHr%hNch{ilLcJ#u}gqwYU` zFqtba-D4knh(1g3n}e+x>ZF%l!j%s5pD}{YFc!q=@Cvu7d4Ixh;mVKdeU(h#qeV>O zrH`_0`$i{-954QW*OC&H^(C49BONDG_^Q;a>EE0^10Fl#aWh;4c~O6IS_E$_1R+jEdE;83OVL^A%yRxvbD;S+%I-@ zS!HcCGK??-gvk2w(A#>O6X^wA*s+1-{EZTvHX)}pgeMmfLRMzZ|3S7)F1>SUK4@s! zhf~+GJdkjG?X_j~BtDC3%NgzkHwAk!^VAdx4?5$wS#rP1C)Rr9kPuyBGpdQ7$y<0@ zmgWN4eV^`G*?OyJUyQRctQD?$-W@if9l)lzc2p?k_1kAIO^k+O1n&hhBTm^C*aeGC zpNhU%HSRna{nDA&KT+!$3$dR$iz}R1e2HG3#5o={Fh$4x06}Cb>)V~IVrjzjI!6IPibDK{I|zO>5V&APIlSE~ol>zA|~bV9?Wp<~Zjw($K_Aasw@}?tG|#kZ1geN?^HCC(?~4iaB^{ zH}ykZk6PG`yV);|Ee!9m7vK^9t@22_M={UO@|yDEg8Wrr%))?Z58j}SnJ3ao+wdNv z1D;m%od~HHWRrn`GafrKqnVyAJ2E~Cm4Z=h(IyG>r{w4J@%y*58E1y%L1%gpJ|Q+3^C{7+HV>Au@OU+ zmlkhb!`r=id{q*;l<}7j*t%$*d%h2)oqj$Pvjp!-kK(XsM{mR6WA@kLdS1x!tf-{8`AiNI3jqo#|Pv z^-Ub6mkv)8jxgHbKWvU!!^r#}EcMD%)ln&t4g<-ml3U+S9c33uA?IOgIGzAnr?uXL zLrXbTHlomm;%rVG@GyRIx|<9blwUG|u4(X4@j1Ydjr_=8&kuijwEfFA5cB1&5}m3> z#`NZ;@ee9(hY=e8YE8soKYqT!yGbO;liEE$%V?+#cZp?>I{gn6aPQbD`n`a5A@ z`4|UKyT!y`<s+`)(SoIxbL`C$B$+tcefZnUqtckhpVhl@x#g&_B#eShfyT2SuZ zXj7-BWJv5Z=MN2hcFBlLwnMkS^a+r3F#*lpZ2NFM-;5Mo$M$Ey64CQFnQrTuM!>wK zbEI?cjJKEJA7%IQlVMstrJf#~DU5DHYS5wn#p$+ZamZB$rq>V@;o>XrHnQbV8^!-hpGU?#RYdA%x>4wg%w#7g+c{>+6ip-SFF?YY@@fk}%m!af0byRDWbcNZa zis`kk|K{nNU9$;$a+knFOV`_a8S8{@|7qkTId$P!gS@SsW-p5m@m{iikeO;mAh>eK z7AWS%)JPPOlnVf2A9-56fqiHv+p#AQantxt^*dn;?Z&W>Kp!H;JMzFu;yH8a=?nC)xQXp9rhX_&-<~2W<_dpRDa=dnb=gE~ha10IHyX64)#N;d(>THstOULcsd{yI zcIgdy37WmAZkrtWNl|kucy;RtZr;q@Vr~D?=-myX`aFn2ZD#Y(flAG>U?r1W-5(#b zRwDxidXA6_gMmr7?&it5kUjBEQ=aVC=#an%H-MYXww`)v0-1y3hdE_ZmkolYQoN&z zlGcry6j|SLH9meH@lOvuw>Q}0N#{*9E09Z913bm7`|U=V3RRh7nYxrm$Nq*AK#u-q z`okf`bCB_%@2cwVqa6K;Vf_vyoY|zF-0D`%pks-Q8HPhMAVQWRZwHy3#oeiD8bX+0 zWj&d}*y@qM34}@3r62Lf*dyPU@T`{xCDOzPb9NXxv-{^432j9HG(-aL6j+}=eZ_8m z)9PidL1pdtiA~zt&l^M=g3=JiVJEDV#O4sD08kr(VM7BhNY-*yY!tJk>+xTf%=F!n zd&FeSlh2&OMPn!;jDR%dw>XnGNmkU?zO^0KPjJCx3=u@_9thb>siG_PzUmcyn(J#0 zneqS@FU`|1+&KU+4-}1uomsXoTwL*uF|?EHT{L7(JDGsI_Ee9GF|2Wqh8$A|U!K+o z9~xIV;Z|Z#tE-*VL>sDe-1d+14PxsX0;T($m4OJ+|H`z_jUGyR>hwZ@4b0KhZX1rJ{p-;Sy>K zC;zKFQEGgq*yztLFU`t+R}K^b%g5Mr<;wr2v+udXy4|Xxt>V@T^8JaY^)uABMa)4^ zY%sm@xsTnyomn{(zp+1fpZtp}|MW~%(>rlbe2{LY=epKwt^L_Q%*k6!lbFRQw#}rp z#|q{({**=b(>wRJFp=`AOV>FbrKsHZrU3!2py>bNfMr_gVpS0_2~WF^4!v&NYLm*M zV_R-pam%*dxi)(wU4!s-iz-T@IwJSTLc=$>xHjy)^pRDMRn?<^lL!RgT=K#1T#<_K z%#oBUDx=psvcCBzm^2G`%KDw|+axIx~aK4aB(`WmmOTHN;7J99;xCyk`|O{Bl8M#dRtJvIyEwf#sU zMqZ4Id;3dA-IM2DcQcqBFV2KAo_Htk%Z;Gu}uczm(HLgiKo8JAU z4AjT47n!t+HsC zkc4dDiHv4`Id7VLm?0mua)wg`dk&zvOT@Senw53O}aS4q)fFB|W>mF^6NV^DX4?$fS( zl6>v0QXew&E%pk)|N62|MB`u%jJ+Csv)V(lDI$J;>if)O&vOA$~F1bL47PMzx0q3ZeQetcSO_dem6*w6ds-mp-_0aVleW zeg95`Pkus^VTfeha*bs3ei^8zz@Qhj#>}2<*w2o%uk$K^i0L`->+ezO!T0d*UUmAb z_x$!}=wfQUtdal9@&2p>-`7+|3EA4AW+!L9I;o&<4b;7`qFU-dpM6p_@EpwzSp^Aa zeRFcMFiCd6W>xK>~6$I&`v;OCZI9Rd~vS9hj3I*6GXO_Lyqat!cQU(I_{H+r0pnO4`u&Y$zJ5&j{**E?FHD32Do%P5(u`_Rvu z3GrMi%*)N|!DK>oUPY+JGB(M6?H*g#1$so^r1kd?!Ob?tpo8~Rzo|zkMZwj-_Dt_S z2BF$I5Yt9&`pM$s{2gDr$gjC`7GSMjb^|xm9?uPH@VqL)b;lAlJQ-zT0D|e@{4+*K z6tjd&U6iF$O+F7jjd(~Q8V%Rg0uRau<|J=83omNTZmiXcs&@Y>;j?i6d9eSF?eXMY zj^ZF8|GU$CHPrbpvyG_W^n^Aj5EMf-@0ItRq=2BmL0;+yD{n3PbTn)`goQyw)5L_$ zH?f}@;nX=WBAVQxElw#H(ubv1q6H#jKRwSIR_BI5qiyjegAde}nU~MJ4(LABNJ;@o zP}?~6CO%a&c_`D!j&Nu?W%BThxhs?GNL}0vWfFehhFA8yGI5xC=S6WZFTV<+Uo#JS>yA%@hDmsQ~e&Gjums9P~A9MLgbncAc8Zihmzsq)P$%AlET8+|qid(xEV67Qit)wa4G8oqhR+f8^9} z0p${JFY&0n1{Gw&fvHptgi4|gPuJ>2nV?CvJDx1-IiCk>zQ8Ak^U zReRrW8GJ@O(VPrD*Brz;W(auM5GiETZR`t&rQ=M2O8agTO7FG%sGszd<{&OdGBKH_ zM92rG6(dUS_yNdq5R-6aplW{H*v~-DpwtL z_9vaTV(%IL^47fvn_IOK5EI+X%V;1ZmOBdJVdb=*_6+#oA=CZ6hs7trE^cA``DjG^ z!>p-&&>kore0_d~DXBMC)$**aI6IW4R|n~%V@aDCRh zG;c<-#-$SKY8dfs-HS-Yn}z|a*LOOhBxWXwIvE5;Z>iCuH)hogm?a_+yg(4~?_B^?ZZ`9n6}!XoQr64ia7_F}4SRzsbb+#TYh)u7w*9?~ z-KP^q4EeWn9SZtXdzMl?fB6}~?yY0^JIKcfABk^NmG0b+jnAS!q46Q05oW@jIp`blKtdXv3+!PBjHfMR#c|q|}a3y&g}gW4~a5HFSxk{7+f- zRrwIgtmE>dUh3^@4J7Ok1kqXKM;wUPvh=Fn&wkAE2W|&h;iIs^O0YN_6#gfc1c6ob63-KBY-jeg#Ga zp{yv5IS156W&XM!#S|eHsE&eKe*QLSm+Q)jiFdGv6F-Ycg$;;?imr00BI?Ee00cvg?f?J) literal 0 HcmV?d00001 diff --git a/device-management-ui/src/assets/images/sort-solid.svg b/device-management-ui/src/assets/images/sort-solid.svg new file mode 100644 index 00000000..36c97cb9 --- /dev/null +++ b/device-management-ui/src/assets/images/sort-solid.svg @@ -0,0 +1 @@ + diff --git a/device-management-ui/src/assets/images/sort-up-solid.svg b/device-management-ui/src/assets/images/sort-up-solid.svg new file mode 100644 index 00000000..6a9ab9d4 --- /dev/null +++ b/device-management-ui/src/assets/images/sort-up-solid.svg @@ -0,0 +1 @@ + diff --git a/device-management-ui/src/assets/scss/_breadcrumb.scss b/device-management-ui/src/assets/scss/_breadcrumb.scss new file mode 100644 index 00000000..494ecc33 --- /dev/null +++ b/device-management-ui/src/assets/scss/_breadcrumb.scss @@ -0,0 +1,23 @@ +ol.breadcrumb { + background-color: transparent; + padding: 0; + + li.breadcrumb-item { + > a { + &:hover { + color: var(--theme); + } + } + &:hover { + cursor: pointer; + } + &:last-of-type { + &:hover { + cursor: auto; + } + span { + color: var(--theme); + } + } + } +} \ No newline at end of file diff --git a/device-management-ui/src/assets/scss/_buttons.scss b/device-management-ui/src/assets/scss/_buttons.scss new file mode 100644 index 00000000..c3c8adf0 --- /dev/null +++ b/device-management-ui/src/assets/scss/_buttons.scss @@ -0,0 +1,95 @@ +button.btn { + color: white; + + &.btn-outline-primary { + color: var(--themedarker); + } + &.btn-outline-secondary { + color: var(--secondary); + } + &.btn-toggle { + color: white; + } + &:focus, &:focus-visible, &:focus-within { + box-shadow: none; + } + &.btn-close { + &:focus { + opacity: var(--bs-btn-close-opacity); + } + } + &.btn-primary { + background-color: var(--theme); + border-color: transparent; + + &:hover,&:focus-visible { + background-color: white; + border-color: var(--theme); + color: var(--theme); + box-shadow: none; + } + + &#displayedItemsDropDown { + height:100%; + } + } + + &.btn-secondary { + &:hover, &:focus-visible { + background-color: white; + color: var(--secondary); + box-shadow: none; + } + } + + &:not(:last-child) { + margin-right: 5px; + } + + > :not(:last-child) { + margin-right: 5px; + position: relative; + top: 1px; + } + + &.active { + background-color: var(--theme); + color: white; + > span.caret { + left: 10px; + top: 36px; + position: absolute; + width: 0; + height: 0; + display: inline-block; + border: 10px solid transparent; + border-top-color: var(--theme); + } + &:hover { + background-color: var(--theme); + color: white; + cursor: auto; + } + } + &.inactive { + background-color: white; + color: var(--theme); + border-color: var(--theme); + &:hover { + background-color: var(--theme); + color: white; + border-color: var(--theme); + } + span.caret { + display: none; + } + } +} + +.page-header { + button { + &:not(last-child) { + margin-right: 5px; + } + } +} diff --git a/device-management-ui/src/assets/scss/_config-accordion.scss b/device-management-ui/src/assets/scss/_config-accordion.scss new file mode 100644 index 00000000..3a2ea547 --- /dev/null +++ b/device-management-ui/src/assets/scss/_config-accordion.scss @@ -0,0 +1,14 @@ +.accordion-item { + .accordion-header { + button.accordion-button { + &:not(.collapsed) { + background-color: var(--theme-light)!important; + color: var(--font)!important; + } + &:focus { + border: none!important; + box-shadow: none!important; + } + } + } +} \ No newline at end of file diff --git a/device-management-ui/src/assets/scss/_date-time-picker.scss b/device-management-ui/src/assets/scss/_date-time-picker.scss new file mode 100644 index 00000000..1e9caae9 --- /dev/null +++ b/device-management-ui/src/assets/scss/_date-time-picker.scss @@ -0,0 +1,73 @@ +/* Datepicker component */ +ngb-datepicker.dropdown-menu { + .ngb-dp-weekday { + color: #2b2b2b; + font-style: normal; + } + + .ngb-dp-navigation-select { + > .form-select { + margin: 0 3px; + } + } + .btn { + color: var(--theme); + } + + .ngb-dp-arrow-btn:focus { + outline: none; + box-shadow: none; + } + + .ngb-dp-navigation-chevron { + border-width: 0.15em 0.15em 0 0; + } + + .ngb-dp-day, + .ngb-dp-week-number, + .ngb-dp-weekday { + margin: 4px; + } +} +/* NGB Time selector */ +.ngb-tp .ngb-tp-input-container { + flex: 1 1 auto; + position: relative; + min-width: 4.8em; + + .btn-link { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + font-size: 9px; + border: 1px solid #2b2b2b; + position: absolute; + right: 0; + height: 50%; + padding: 0 10px; + + &:hover { + background-color: #2b2b2b; + } + } + + .form-control { + padding-left: 6px; + padding-right: 35px; + } + + .btn-link:first-child { + border-bottom-right-radius: 0; + border-bottom: none; + top: 0; + } + + .btn-link:last-child { + border-top-right-radius: 0; + border-top: none; + bottom: 0; + } + button.btn:not(:last-child) { + margin-right: 0; + } +} + diff --git a/device-management-ui/src/assets/scss/_layout.scss b/device-management-ui/src/assets/scss/_layout.scss new file mode 100644 index 00000000..8a1597b5 --- /dev/null +++ b/device-management-ui/src/assets/scss/_layout.scss @@ -0,0 +1,106 @@ +body { + height: 100vh; + background-color: var(--backgroundlight)!important; +} +.content { + max-height: 100%; + padding: 2em; + + .page-header { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 0 1.5em 0; + min-height: 38px; + padding: 0; + position: relative; + + .page-header-counts { + border-left: 1px solid gray; + display: flex; + align-items: center; + margin-left: 10%; + padding-left: 10%; + + .count { + display: flex; + align-items: center; + margin-right: 10em; + + .badge { + margin-right: 0.5em; + color: white; + width: auto; + border: 1px solid white; + } + } + } + + .page-header-title { + .h3 { + max-width: 45vw; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-bottom: 0; + } + } + + .page-functions { + display: flex; + + .function-element { + display: flex; + position: relative; + + .display-items { + padding-left: 5px; + } + } + } + } + + a { + color: #333; + &:hover { + text-decoration: none; + color: var(--theme); + } + } + + small { + color: #a1a1a1; + font-size: 0.8em; + } + + .input-group .ng-select { + flex: 1 1 auto; + } + +} + +.toast { + position: absolute; + top: 0; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + width: auto; + + .toast-body { + word-break: break-word; + text-align: center; + } +} + +.table thead { + position: sticky; + top: 0; + background-color: white; +} + +.timezoneOffset { + margin-top: 5px; +} diff --git a/device-management-ui/src/assets/scss/_lists.scss b/device-management-ui/src/assets/scss/_lists.scss new file mode 100644 index 00000000..b3ec3103 --- /dev/null +++ b/device-management-ui/src/assets/scss/_lists.scss @@ -0,0 +1,105 @@ +.list-head, .list-item { + .ml-auto { + margin-left: auto !important; + } +} + +.table { + th { + font-weight: 600; + line-height: 1.2; + } + + .sortable { + &:hover { + color: var(--secondary); + cursor: pointer; + + span:before, + span:after { + color: var(--secondary) !important; + } + } + + &.sort-desc, + &.sort-asc { + span:after { + font-weight: 600; + content: url('../../assets/images/sort-up-solid.svg'); + display: block; + width: 12px; + height: 20px; + } + } + + &.sort-desc { + span:before, + span:after { + transform: rotate(180deg); + } + } + + > span { + display: inline-flex; + padding-right: 24px; + position: relative; + + &:before, + &:after { + transition: all 250ms ease; + color: black; + font-weight: 600; + font-size: 20px; + align-items: center; + justify-content: center; + line-height: 1; + display: flex; + height: 100%; + width: 20px; + position: absolute; + top: 0; + right: 0; + } + + &:before { + content: url('../../assets/images/sort-solid.svg'); + display: block; + width: 12px; + height: 20px; + opacity: 0.25; + z-index: 1; + } + + &:after { + z-index: 2; + } + } + } + + .list-item { + border-top: 1px solid #ccc; + font-size: 15px; + &:hover { + background-color: var(--backgroundlight); + } + > .selectElement { + cursor: pointer; + color: black; + } + + .last-column { + button { + padding: 2px 5px; + + &:first-child { + margin-right: 5px; + } + } + } + + .form-check, .form-switch { + font-size: 14px; + padding-top: 0.5em; + } + } +} diff --git a/device-management-ui/src/assets/scss/_loader.scss b/device-management-ui/src/assets/scss/_loader.scss new file mode 100644 index 00000000..fb41163f --- /dev/null +++ b/device-management-ui/src/assets/scss/_loader.scss @@ -0,0 +1,16 @@ +.loader { + position: fixed; + display: flex; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 99999; + background:rgb(255, 255, 255, 0.5); + } + + .spinner-border { + position: fixed; + z-index: 99999; + top:45%; + } \ No newline at end of file diff --git a/device-management-ui/src/assets/scss/_login.scss b/device-management-ui/src/assets/scss/_login.scss new file mode 100644 index 00000000..58395e67 --- /dev/null +++ b/device-management-ui/src/assets/scss/_login.scss @@ -0,0 +1,32 @@ +.login-form { + border-radius: 2px; + box-shadow: 0 3px 20px rgba(black, 0.25); + background-color: white; + padding: 2em 2em 2em; +} + +.login { + background: var(--theme); + display: flex; + align-items: center; + justify-content: center; + position: fixed; + left: 0; + top: 0; + height: 100%; + width: 100%; + + .page-header { + display: flex; + justify-content: center; + border-bottom-color: var(--dyn-primary); + position: relative; + + .logo { + height: 100px; + width: 100%; + } + + } + +} diff --git a/device-management-ui/src/assets/scss/_logo.scss b/device-management-ui/src/assets/scss/_logo.scss new file mode 100644 index 00000000..f4e0e680 --- /dev/null +++ b/device-management-ui/src/assets/scss/_logo.scss @@ -0,0 +1,7 @@ +.logo { + height: 20px; + width: auto; + top: 2px; + position: relative; + vertical-align: unset; +} \ No newline at end of file diff --git a/device-management-ui/src/assets/scss/_modals.scss b/device-management-ui/src/assets/scss/_modals.scss new file mode 100644 index 00000000..8b0b611b --- /dev/null +++ b/device-management-ui/src/assets/scss/_modals.scss @@ -0,0 +1,50 @@ +.modal-header .close { + outline: none !important; +} + +ngb-modal-backdrop { + z-index: 1050 !important; +} + +.modal-component { + width: 90%; + margin: 0.5rem auto; + + .modal-header { + .modal-title { + font-size: 1.2rem; + font-weight: 500; + } + } + + .modal-body { + .form-group { + margin: 1rem 0; + + label { + margin-bottom: 0.5rem; + font-weight: 550; + font-size: 1rem; + padding-right: 2px; + } + + h6 { + font-size: 1.1rem; + } + } + + .form-check:first-child { + margin-top: 1rem; + } + + .modal-buttons { + margin-top: 2rem; + } + + } + + .modal-footer-inner { + width: 100%; + } + +} diff --git a/device-management-ui/src/assets/scss/_pagination.scss b/device-management-ui/src/assets/scss/_pagination.scss new file mode 100644 index 00000000..cb2d3413 --- /dev/null +++ b/device-management-ui/src/assets/scss/_pagination.scss @@ -0,0 +1,25 @@ +.pagination-wrapper { + position: relative; + .page-size { + position: absolute; + padding-top: 1em; + right: 0; + } +} + +.pagination { + padding-top: 1em; + .page-item { + &.disabled { + > .page-link { + background-color: transparent; + } + } + + .page-link { + > .visually-hidden { + display:none; + } + } + } +} diff --git a/device-management-ui/src/assets/scss/_responsive.scss b/device-management-ui/src/assets/scss/_responsive.scss new file mode 100644 index 00000000..7a2b83bf --- /dev/null +++ b/device-management-ui/src/assets/scss/_responsive.scss @@ -0,0 +1,55 @@ +@media (max-width: 480px) { + .page { + font-size: small; + .content { + padding: 1em; + .breadcrumb { + margin-bottom: 0.5rem; + } + .page-header { + display: block; + .page-header-title { + margin-bottom: 1em; + .page-header-counts { + .count { + .count-label { + display: none; + } + } + } + .h3 { + max-width: unset; + } + } + .page-functions { + display: inline-block; + width: 100%; + .function-element { + display: inline-block; + width: 100%; + &:not(:first-of-type) { + width: 100%; + margin: 5px 0 0 0; + } + .display-items { + padding-left: 0; + } + .btn { + width: 100%; + } + } + &.small-buttons { + .function-element { + display: inline-flex; + width: auto; + } + } + } + } + .tab-content { + max-width: 100vw; + overflow: scroll; + } + } + } +} \ No newline at end of file diff --git a/device-management-ui/src/assets/scss/_toasts.scss b/device-management-ui/src/assets/scss/_toasts.scss new file mode 100644 index 00000000..c8587f1d --- /dev/null +++ b/device-management-ui/src/assets/scss/_toasts.scss @@ -0,0 +1,50 @@ +/* Overwrite toast styling */ +.toast { + color: grey !important; + width: 320px; + position: relative; + + .toast-header { + color: grey; + } + + .close { + text-shadow: none; + opacity: 1; + } + + &.bg-success, + &.bg-warning, + &.bg-danger, + &.bg-info { + background-color: white !important; + } + + &.text-light { + color: grey !important; + } + + &.bg-success { + border-left: 4px solid var(--success); + + .toast-header { + color: var(--success); + } + } + + &.bg-warning { + border-left: 4px solid var(--warning); + + .toast-header { + color: var(--warning); + } + } + + &.bg-danger { + border-left: 4px solid var(--danger); + + .toast-header { + color: var(--danger); + } + } +} \ No newline at end of file diff --git a/device-management-ui/src/environments/environment.development.ts b/device-management-ui/src/environments/environment.development.ts new file mode 100644 index 00000000..53aa56d9 --- /dev/null +++ b/device-management-ui/src/environments/environment.development.ts @@ -0,0 +1,21 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Environment} from "../app/models/environment"; + +export const environment: Environment = { + production: false, + googleClientId: '' +}; diff --git a/device-management-ui/src/environments/environment.ts b/device-management-ui/src/environments/environment.ts new file mode 100644 index 00000000..a6ba90a5 --- /dev/null +++ b/device-management-ui/src/environments/environment.ts @@ -0,0 +1,21 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {Environment} from "../app/models/environment"; + +export const environment: Environment = { + production: true, + googleClientId: window['env'].GOOGLE_CLIENT_ID +}; diff --git a/device-management-ui/src/index.html b/device-management-ui/src/index.html new file mode 100644 index 00000000..85809538 --- /dev/null +++ b/device-management-ui/src/index.html @@ -0,0 +1,16 @@ + + + + + + Device Management + + + + + +
+ +
+ + diff --git a/device-management-ui/src/main.ts b/device-management-ui/src/main.ts new file mode 100644 index 00000000..a320dde6 --- /dev/null +++ b/device-management-ui/src/main.ts @@ -0,0 +1,22 @@ +/* + * ******************************************************************************* + * * Copyright (c) 2023 Contributors to the Eclipse Foundation + * * + * * See the NOTICE file(s) distributed with this work for additional + * * information regarding copyright ownership. + * * + * * This program and the accompanying materials are made available under the + * * terms of the Eclipse Public License 2.0 which is available at + * * http://www.eclipse.org/legal/epl-2.0 + * * + * * SPDX-License-Identifier: EPL-2.0 + * ******************************************************************************* + */ + +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; + +import {AppModule} from './app/app.module'; + + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/device-management-ui/src/styles.scss b/device-management-ui/src/styles.scss new file mode 100644 index 00000000..e5dc6c0f --- /dev/null +++ b/device-management-ui/src/styles.scss @@ -0,0 +1,23 @@ +/* Import variables */ +@import "./assets/variables"; + +/* Import Asset Stylings */ +@import "./assets/scss/layout"; +@import "./assets/scss/buttons"; +@import "./assets/scss/modals"; +@import "./assets/scss/lists"; +@import "./assets/scss/login"; +@import "./assets/scss/breadcrumb"; +@import "./assets/scss/pagination"; +@import "./assets/scss/date-time-picker"; +@import "./assets/scss/toasts"; +@import "./assets/scss/loader"; +@import "./assets/scss/logo"; +@import "./assets/scss/config-accordion"; + +/* Responsive Styles Media Queries */ +@import "./assets/scss/responsive.scss"; + +/* Importing Bootstrap SCSS file. */ +@import "node_modules/bootstrap/scss/bootstrap"; +@import "~@ng-select/ng-select/themes/default.theme.css"; diff --git a/device-management-ui/tsconfig.app.json b/device-management-ui/tsconfig.app.json new file mode 100644 index 00000000..43ba0f8e --- /dev/null +++ b/device-management-ui/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [ + "@angular/localize" + ] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/device-management-ui/tsconfig.json b/device-management-ui/tsconfig.json new file mode 100644 index 00000000..2207492f --- /dev/null +++ b/device-management-ui/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "suppressImplicitAnyIndexErrors": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/device-management-ui/tsconfig.spec.json b/device-management-ui/tsconfig.spec.json new file mode 100644 index 00000000..d9a3f004 --- /dev/null +++ b/device-management-ui/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine", + "@angular/localize" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}