From c92f737237df90476cac5a0b0fc8f1b60c77d226 Mon Sep 17 00:00:00 2001 From: Mumshad Mannambeth Date: Wed, 7 Jun 2017 13:36:44 -0400 Subject: [PATCH] Initial Commit --- .babelrc | 6 + .buildignore | 0 .editorconfig | 21 + .eslintrc | 254 ++++++ .gitattributes | 43 + .gitignore | 10 + .travis.yml | 21 + .yo-rc.json | 72 ++ Dockerfile | 49 ++ README.md | 28 + client/.eslintrc | 7 + client/.htaccess | 543 ++++++++++++ client/_index.html | 36 + client/app/account/account.routes.js | 37 + client/app/account/index.js | 24 + client/app/account/login/index.js | 8 + client/app/account/login/login.controller.js | 38 + client/app/account/login/login.html | 53 ++ client/app/account/settings/index.js | 8 + .../account/settings/settings.controller.js | 36 + client/app/account/settings/settings.html | 51 ++ client/app/account/signup/index.js | 8 + .../app/account/signup/signup.controller.js | 45 + client/app/account/signup/signup.html | 86 ++ client/app/admin/admin.controller.js | 14 + client/app/admin/admin.css | 19 + client/app/admin/admin.html | 12 + client/app/admin/admin.routes.js | 13 + client/app/admin/index.js | 10 + client/app/app.config.js | 9 + client/app/app.constants.js | 7 + client/app/app.css | 125 +++ client/app/app.js | 126 +++ .../custom_modules.component.js | 235 +++++ .../custom_modules.component.spec.js | 17 + client/app/custom_modules/custom_modules.html | 55 ++ .../custom_modules/custom_modules.routes.js | 10 + .../custom_modules/custom_modules.service.js | 30 + .../custom_modules.service.spec.js | 16 + .../new_module/new_module.controller.js | 280 ++++++ .../new_module/new_module.controller.spec.js | 17 + .../custom_modules/new_module/new_module.html | 144 ++++ client/app/designer/designer.component.js | 89 ++ .../app/designer/designer.component.spec.js | 17 + client/app/designer/designer.css | 10 + client/app/designer/designer.html | 55 ++ client/app/designer/designer.routes.js | 10 + .../app/designer/execution/executeModal.html | 185 ++++ .../execution/execution.controller.js | 475 ++++++++++ .../execution/execution.controller.spec.js | 17 + .../file_browser/file_browser.component.js | 104 +++ .../file_browser.component.spec.js | 17 + .../designer/file_browser/file_browser.html | 33 + .../file_browser/file_browser.routes.js | 10 + .../designer/inventory/inventory.component.js | 278 ++++++ .../inventory/inventory.component.spec.js | 17 + client/app/designer/inventory/inventory.css | 0 client/app/designer/inventory/inventory.html | 154 ++++ .../designer/inventory/inventory.routes.js | 10 + .../new_group/new_group.controller.js | 82 ++ .../new_group/new_group.controller.spec.js | 17 + .../inventory/new_group/new_group.html | 45 + .../inventory/new_host/new_host.controller.js | 69 ++ .../new_host/new_host.controller.spec.js | 17 + .../designer/inventory/new_host/new_host.html | 46 + .../new_inventory/new_inventory.controller.js | 40 + .../new_inventory.controller.spec.js | 17 + .../new_inventory/new_inventory.html | 21 + .../playbook/new_play/new_play.controller.js | 134 +++ .../new_play/new_play.controller.spec.js | 17 + .../designer/playbook/new_play/new_play.html | 96 +++ .../new_playbook/new_playbook.controller.js | 35 + .../new_playbook.controller.spec.js | 17 + .../playbook/new_playbook/new_playbook.html | 19 + .../designer/playbook/playbook.component.js | 328 +++++++ .../playbook/playbook.component.spec.js | 17 + client/app/designer/playbook/playbook.css | 0 client/app/designer/playbook/playbook.html | 166 ++++ .../app/designer/playbook/playbook.routes.js | 10 + .../roles/new_file/new_file.controller.js | 58 ++ .../new_file/new_file.controller.spec.js | 17 + .../app/designer/roles/new_file/new_file.html | 19 + .../roles/new_role/new_role.controller.js | 56 ++ .../new_role/new_role.controller.spec.js | 17 + .../app/designer/roles/new_role/new_role.html | 19 + client/app/designer/roles/roles.component.js | 463 ++++++++++ .../designer/roles/roles.component.spec.js | 17 + client/app/designer/roles/roles.css | 0 client/app/designer/roles/roles.html | 110 +++ client/app/designer/roles/roles.routes.js | 10 + .../search_role/search_role.controller.js | 66 ++ .../search_role.controller.spec.js | 17 + .../roles/search_role/search_role.html | 70 ++ .../tasks/new_task/new_task.controller.js | 446 ++++++++++ .../new_task/new_task.controller.spec.js | 17 + .../app/designer/tasks/new_task/new_task.html | 134 +++ client/app/designer/tasks/tasks.css | 0 client/app/designer/tasks/tasks.directive.js | 229 +++++ .../designer/tasks/tasks.directive.spec.js | 20 + client/app/designer/tasks/tasks.html | 53 ++ .../complexVar/complexVar.controller.js | 60 ++ .../complexVar/complexVar.controller.spec.js | 17 + .../app/directives/complexVar/complexVar.css | 8 + .../complexVar/complexVar.directive.js | 20 + .../complexVar/complexVar.directive.spec.js | 20 + .../app/directives/complexVar/complexVar.html | 38 + .../filters/addDotInKey/addDotInKey.filter.js | 14 + .../addDotInKey/addDotInKey.filter.spec.js | 17 + .../dictToKeyValueArray.filter.js | 29 + .../dictToKeyValueArray.filter.spec.js | 17 + .../dictToKeyValueArraySimple.filter.js | 20 + .../dictToKeyValueArraySimple.filter.spec.js | 17 + .../app/filters/json2yaml/json2yaml.filter.js | 118 +++ .../json2yaml/json2yaml.filter.spec.js | 17 + .../keyValueArrayToArray.filter.js | 18 + .../keyValueArrayToArray.filter.spec.js | 17 + .../keyValueArrayToDict.filter.js | 18 + .../keyValueArrayToDict.filter.spec.js | 17 + .../removeDotInKey/removeDotInKey.filter.js | 18 + .../removeDotInKey.filter.spec.js | 17 + client/app/main/main.component.js | 42 + client/app/main/main.component.spec.js | 37 + client/app/main/main.css | 27 + client/app/main/main.html | 82 ++ client/app/main/main.routes.js | 10 + .../complex_var_modal/complexVariable.html | 19 + .../complex_var_modal.controller.js | 21 + .../complex_var_modal.controller.spec.js | 17 + client/app/project/project.component.js | 121 +++ client/app/project/project.component.spec.js | 17 + client/app/project/project.css | 5 + client/app/project/project.html | 80 ++ client/app/project/project.routes.js | 10 + .../providers/ansi2html/ansi2html.service.js | 32 + .../ansi2html/ansi2html.service.spec.js | 16 + client/app/providers/yaml/yaml.service.js | 30 + .../app/providers/yaml/yaml.service.spec.js | 16 + client/app/runs/runs.component.js | 124 +++ client/app/runs/runs.component.spec.js | 17 + client/app/runs/runs.css | 0 client/app/runs/runs.html | 44 + client/app/runs/runs.routes.js | 10 + .../app/services/ansible/ansible.service.js | 302 +++++++ .../services/ansible/ansible.service.spec.js | 16 + client/app/services/editor/editor.service.js | 48 ++ .../services/editor/editor.service.spec.js | 16 + .../app/services/projects/projects.service.js | 20 + .../projects/projects.service.spec.js | 16 + client/app/services/yaml/yaml.service.js | 19 + client/app/services/yaml/yaml.service.spec.js | 16 + .../app/services/yamlFile/yamlFile.service.js | 24 + .../yamlFile/yamlFile.service.spec.js | 16 + client/assets/fonts/Arual.ttf | Bin 0 -> 21492 bytes client/assets/fonts/ExpletusSans-Regular.ttf | Bin 0 -> 49772 bytes client/assets/fonts/PUDDLE.otf | Bin 0 -> 25904 bytes client/assets/fonts/PUDDLE.ttf | Bin 0 -> 39308 bytes client/assets/images/Button-1-play-icon.png | Bin 0 -> 4897 bytes client/assets/images/FluSchedule.ics | 22 + client/assets/images/ansible_icon.png | Bin 0 -> 393 bytes .../images/browser-google-chrome-icon.png | Bin 0 -> 6644 bytes client/assets/images/ehc-ozone.png | Bin 0 -> 14444 bytes client/assets/images/emcacademy.png | Bin 0 -> 50025 bytes client/assets/images/inside-emc-black.png | Bin 0 -> 18207 bytes client/assets/images/inside-emc.png | Bin 0 -> 16235 bytes client/assets/images/inventory.png | Bin 0 -> 27775 bytes client/assets/images/migrato.png | Bin 0 -> 17905 bytes client/assets/images/play.png | Bin 0 -> 10946 bytes client/assets/images/playbook.png | Bin 0 -> 51480 bytes client/assets/images/yeoman.png | Bin 0 -> 12331 bytes client/components/auth/auth.module.js | 34 + client/components/auth/auth.service.js | 226 +++++ client/components/auth/interceptor.service.js | 28 + client/components/auth/router.decorator.js | 38 + client/components/auth/user.service.js | 22 + client/components/footer/footer.component.js | 10 + client/components/footer/footer.css | 6 + client/components/footer/footer.html | 8 + client/components/modal/modal.css | 23 + client/components/modal/modal.html | 11 + client/components/modal/modal.service.js | 78 ++ .../mongoose-error.directive.js | 17 + client/components/navbar/navbar.component.js | 41 + client/components/navbar/navbar.html | 29 + client/components/oauth-buttons/index.js | 25 + .../oauth-buttons.controller.spec.js | 32 + .../oauth-buttons/oauth-buttons.css | 1 + .../oauth-buttons.directive.spec.js | 59 ++ .../oauth-buttons/oauth-buttons.html | 9 + client/components/ui-router/ui-router.mock.js | 38 + client/components/util/util.module.js | 10 + client/components/util/util.service.js | 68 ++ client/favicon.ico | Bin 0 -> 6774 bytes client/polyfills.js | 28 + client/robots.txt | 3 + e2e/account/login/login.po.js | 28 + e2e/account/login/login.spec.js | 87 ++ e2e/account/logout/logout.spec.js | 51 ++ e2e/account/signup/signup.po.js | 30 + e2e/account/signup/signup.spec.js | 84 ++ e2e/components/navbar/navbar.po.js | 16 + .../oauth-buttons/oauth-buttons.po.js | 14 + e2e/main/main.po.js | 15 + e2e/main/main.spec.js | 19 + gulpfile.babel.js | 596 +++++++++++++ karma.conf.js | 98 +++ mocha.conf.js | 19 + mocha.global.js | 8 + package.json | 151 ++++ protractor.conf.js | 81 ++ server/.eslintrc | 6 + server/api/ansible/ansible.controller.js | 814 ++++++++++++++++++ server/api/ansible/ansible.events.js | 35 + server/api/ansible/ansible.integration.js | 190 ++++ server/api/ansible/ansible.model.js | 24 + server/api/ansible/index.js | 59 ++ server/api/ansible/index.spec.js | 86 ++ .../custom_module/custom_module.controller.js | 220 +++++ .../api/custom_module/custom_module.events.js | 35 + .../custom_module.integration.js | 190 ++++ .../api/custom_module/custom_module.model.js | 13 + server/api/custom_module/index.js | 16 + server/api/custom_module/index.spec.js | 86 ++ server/api/project/index.js | 15 + server/api/project/index.spec.js | 86 ++ server/api/project/project.controller.js | 152 ++++ server/api/project/project.events.js | 35 + server/api/project/project.integration.js | 190 ++++ server/api/project/project.model.js | 22 + server/api/thing/index.js | 15 + server/api/thing/index.spec.js | 86 ++ server/api/thing/thing.controller.js | 118 +++ server/api/thing/thing.events.js | 35 + server/api/thing/thing.integration.js | 190 ++++ server/api/thing/thing.model.js | 13 + server/api/user/index.js | 16 + server/api/user/index.spec.js | 95 ++ server/api/user/user.controller.js | 122 +++ server/api/user/user.events.js | 35 + server/api/user/user.integration.js | 67 ++ server/api/user/user.model.js | 258 ++++++ server/api/user/user.model.spec.js | 164 ++++ server/app.js | 38 + server/auth/auth.service.js | 82 ++ server/auth/facebook/index.js | 20 + server/auth/facebook/passport.js | 34 + server/auth/google/index.js | 23 + server/auth/google/passport.js | 31 + server/auth/index.js | 17 + server/auth/local/index.js | 24 + server/auth/local/passport.js | 35 + server/components/ansible/ansible_tool.js | 658 ++++++++++++++ server/components/errors/index.js | 22 + server/components/pythonParser.js | 75 ++ server/components/scp/scp_exec.js | 123 +++ server/components/ssh/ssh2_exec.js | 108 +++ server/components/upgrade/upgradetool.js | 116 +++ server/components/utils/dbutility.js | 31 + server/config/environment/development.js | 16 + server/config/environment/index.js | 66 ++ server/config/environment/production.js | 24 + server/config/environment/shared.js | 11 + server/config/environment/test.js | 21 + server/config/express.js | 133 +++ server/config/local.env.sample.js | 20 + server/config/seed.js | 66 ++ server/index.js | 12 + server/routes.js | 29 + server/views/404.html | 157 ++++ spec.js | 12 + webpack.build.js | 8 + webpack.dev.js | 8 + webpack.make.js | 363 ++++++++ webpack.test.js | 8 + 273 files changed, 16964 insertions(+) create mode 100644 .babelrc create mode 100644 .buildignore create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 .yo-rc.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/.eslintrc create mode 100644 client/.htaccess create mode 100644 client/_index.html create mode 100644 client/app/account/account.routes.js create mode 100644 client/app/account/index.js create mode 100644 client/app/account/login/index.js create mode 100644 client/app/account/login/login.controller.js create mode 100644 client/app/account/login/login.html create mode 100644 client/app/account/settings/index.js create mode 100644 client/app/account/settings/settings.controller.js create mode 100644 client/app/account/settings/settings.html create mode 100644 client/app/account/signup/index.js create mode 100644 client/app/account/signup/signup.controller.js create mode 100644 client/app/account/signup/signup.html create mode 100644 client/app/admin/admin.controller.js create mode 100644 client/app/admin/admin.css create mode 100644 client/app/admin/admin.html create mode 100644 client/app/admin/admin.routes.js create mode 100644 client/app/admin/index.js create mode 100644 client/app/app.config.js create mode 100644 client/app/app.constants.js create mode 100644 client/app/app.css create mode 100644 client/app/app.js create mode 100644 client/app/custom_modules/custom_modules.component.js create mode 100644 client/app/custom_modules/custom_modules.component.spec.js create mode 100644 client/app/custom_modules/custom_modules.html create mode 100644 client/app/custom_modules/custom_modules.routes.js create mode 100644 client/app/custom_modules/custom_modules.service.js create mode 100644 client/app/custom_modules/custom_modules.service.spec.js create mode 100644 client/app/custom_modules/new_module/new_module.controller.js create mode 100644 client/app/custom_modules/new_module/new_module.controller.spec.js create mode 100644 client/app/custom_modules/new_module/new_module.html create mode 100644 client/app/designer/designer.component.js create mode 100644 client/app/designer/designer.component.spec.js create mode 100644 client/app/designer/designer.css create mode 100644 client/app/designer/designer.html create mode 100644 client/app/designer/designer.routes.js create mode 100644 client/app/designer/execution/executeModal.html create mode 100644 client/app/designer/execution/execution.controller.js create mode 100644 client/app/designer/execution/execution.controller.spec.js create mode 100644 client/app/designer/file_browser/file_browser.component.js create mode 100644 client/app/designer/file_browser/file_browser.component.spec.js create mode 100644 client/app/designer/file_browser/file_browser.html create mode 100644 client/app/designer/file_browser/file_browser.routes.js create mode 100644 client/app/designer/inventory/inventory.component.js create mode 100644 client/app/designer/inventory/inventory.component.spec.js create mode 100644 client/app/designer/inventory/inventory.css create mode 100644 client/app/designer/inventory/inventory.html create mode 100644 client/app/designer/inventory/inventory.routes.js create mode 100644 client/app/designer/inventory/new_group/new_group.controller.js create mode 100644 client/app/designer/inventory/new_group/new_group.controller.spec.js create mode 100644 client/app/designer/inventory/new_group/new_group.html create mode 100644 client/app/designer/inventory/new_host/new_host.controller.js create mode 100644 client/app/designer/inventory/new_host/new_host.controller.spec.js create mode 100644 client/app/designer/inventory/new_host/new_host.html create mode 100644 client/app/designer/inventory/new_inventory/new_inventory.controller.js create mode 100644 client/app/designer/inventory/new_inventory/new_inventory.controller.spec.js create mode 100644 client/app/designer/inventory/new_inventory/new_inventory.html create mode 100644 client/app/designer/playbook/new_play/new_play.controller.js create mode 100644 client/app/designer/playbook/new_play/new_play.controller.spec.js create mode 100644 client/app/designer/playbook/new_play/new_play.html create mode 100644 client/app/designer/playbook/new_playbook/new_playbook.controller.js create mode 100644 client/app/designer/playbook/new_playbook/new_playbook.controller.spec.js create mode 100644 client/app/designer/playbook/new_playbook/new_playbook.html create mode 100644 client/app/designer/playbook/playbook.component.js create mode 100644 client/app/designer/playbook/playbook.component.spec.js create mode 100644 client/app/designer/playbook/playbook.css create mode 100644 client/app/designer/playbook/playbook.html create mode 100644 client/app/designer/playbook/playbook.routes.js create mode 100644 client/app/designer/roles/new_file/new_file.controller.js create mode 100644 client/app/designer/roles/new_file/new_file.controller.spec.js create mode 100644 client/app/designer/roles/new_file/new_file.html create mode 100644 client/app/designer/roles/new_role/new_role.controller.js create mode 100644 client/app/designer/roles/new_role/new_role.controller.spec.js create mode 100644 client/app/designer/roles/new_role/new_role.html create mode 100644 client/app/designer/roles/roles.component.js create mode 100644 client/app/designer/roles/roles.component.spec.js create mode 100644 client/app/designer/roles/roles.css create mode 100644 client/app/designer/roles/roles.html create mode 100644 client/app/designer/roles/roles.routes.js create mode 100644 client/app/designer/roles/search_role/search_role.controller.js create mode 100644 client/app/designer/roles/search_role/search_role.controller.spec.js create mode 100644 client/app/designer/roles/search_role/search_role.html create mode 100644 client/app/designer/tasks/new_task/new_task.controller.js create mode 100644 client/app/designer/tasks/new_task/new_task.controller.spec.js create mode 100644 client/app/designer/tasks/new_task/new_task.html create mode 100644 client/app/designer/tasks/tasks.css create mode 100644 client/app/designer/tasks/tasks.directive.js create mode 100644 client/app/designer/tasks/tasks.directive.spec.js create mode 100644 client/app/designer/tasks/tasks.html create mode 100644 client/app/directives/complexVar/complexVar.controller.js create mode 100644 client/app/directives/complexVar/complexVar.controller.spec.js create mode 100644 client/app/directives/complexVar/complexVar.css create mode 100644 client/app/directives/complexVar/complexVar.directive.js create mode 100644 client/app/directives/complexVar/complexVar.directive.spec.js create mode 100644 client/app/directives/complexVar/complexVar.html create mode 100644 client/app/filters/addDotInKey/addDotInKey.filter.js create mode 100644 client/app/filters/addDotInKey/addDotInKey.filter.spec.js create mode 100644 client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.js create mode 100644 client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.spec.js create mode 100644 client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.js create mode 100644 client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.spec.js create mode 100644 client/app/filters/json2yaml/json2yaml.filter.js create mode 100644 client/app/filters/json2yaml/json2yaml.filter.spec.js create mode 100644 client/app/filters/keyValueArrayToArray/keyValueArrayToArray.filter.js create mode 100644 client/app/filters/keyValueArrayToArray/keyValueArrayToArray.filter.spec.js create mode 100644 client/app/filters/keyValueArrayToDict/keyValueArrayToDict.filter.js create mode 100644 client/app/filters/keyValueArrayToDict/keyValueArrayToDict.filter.spec.js create mode 100644 client/app/filters/removeDotInKey/removeDotInKey.filter.js create mode 100644 client/app/filters/removeDotInKey/removeDotInKey.filter.spec.js create mode 100644 client/app/main/main.component.js create mode 100644 client/app/main/main.component.spec.js create mode 100644 client/app/main/main.css create mode 100644 client/app/main/main.html create mode 100644 client/app/main/main.routes.js create mode 100644 client/app/modals/complex_var_modal/complexVariable.html create mode 100644 client/app/modals/complex_var_modal/complex_var_modal.controller.js create mode 100644 client/app/modals/complex_var_modal/complex_var_modal.controller.spec.js create mode 100644 client/app/project/project.component.js create mode 100644 client/app/project/project.component.spec.js create mode 100644 client/app/project/project.css create mode 100644 client/app/project/project.html create mode 100644 client/app/project/project.routes.js create mode 100644 client/app/providers/ansi2html/ansi2html.service.js create mode 100644 client/app/providers/ansi2html/ansi2html.service.spec.js create mode 100644 client/app/providers/yaml/yaml.service.js create mode 100644 client/app/providers/yaml/yaml.service.spec.js create mode 100644 client/app/runs/runs.component.js create mode 100644 client/app/runs/runs.component.spec.js create mode 100644 client/app/runs/runs.css create mode 100644 client/app/runs/runs.html create mode 100644 client/app/runs/runs.routes.js create mode 100644 client/app/services/ansible/ansible.service.js create mode 100644 client/app/services/ansible/ansible.service.spec.js create mode 100644 client/app/services/editor/editor.service.js create mode 100644 client/app/services/editor/editor.service.spec.js create mode 100644 client/app/services/projects/projects.service.js create mode 100644 client/app/services/projects/projects.service.spec.js create mode 100644 client/app/services/yaml/yaml.service.js create mode 100644 client/app/services/yaml/yaml.service.spec.js create mode 100644 client/app/services/yamlFile/yamlFile.service.js create mode 100644 client/app/services/yamlFile/yamlFile.service.spec.js create mode 100644 client/assets/fonts/Arual.ttf create mode 100644 client/assets/fonts/ExpletusSans-Regular.ttf create mode 100644 client/assets/fonts/PUDDLE.otf create mode 100644 client/assets/fonts/PUDDLE.ttf create mode 100644 client/assets/images/Button-1-play-icon.png create mode 100644 client/assets/images/FluSchedule.ics create mode 100644 client/assets/images/ansible_icon.png create mode 100644 client/assets/images/browser-google-chrome-icon.png create mode 100644 client/assets/images/ehc-ozone.png create mode 100644 client/assets/images/emcacademy.png create mode 100644 client/assets/images/inside-emc-black.png create mode 100644 client/assets/images/inside-emc.png create mode 100644 client/assets/images/inventory.png create mode 100644 client/assets/images/migrato.png create mode 100644 client/assets/images/play.png create mode 100644 client/assets/images/playbook.png create mode 100644 client/assets/images/yeoman.png create mode 100644 client/components/auth/auth.module.js create mode 100644 client/components/auth/auth.service.js create mode 100644 client/components/auth/interceptor.service.js create mode 100644 client/components/auth/router.decorator.js create mode 100644 client/components/auth/user.service.js create mode 100644 client/components/footer/footer.component.js create mode 100644 client/components/footer/footer.css create mode 100644 client/components/footer/footer.html create mode 100644 client/components/modal/modal.css create mode 100644 client/components/modal/modal.html create mode 100644 client/components/modal/modal.service.js create mode 100644 client/components/mongoose-error/mongoose-error.directive.js create mode 100644 client/components/navbar/navbar.component.js create mode 100644 client/components/navbar/navbar.html create mode 100644 client/components/oauth-buttons/index.js create mode 100644 client/components/oauth-buttons/oauth-buttons.controller.spec.js create mode 100644 client/components/oauth-buttons/oauth-buttons.css create mode 100644 client/components/oauth-buttons/oauth-buttons.directive.spec.js create mode 100644 client/components/oauth-buttons/oauth-buttons.html create mode 100644 client/components/ui-router/ui-router.mock.js create mode 100644 client/components/util/util.module.js create mode 100644 client/components/util/util.service.js create mode 100644 client/favicon.ico create mode 100644 client/polyfills.js create mode 100644 client/robots.txt create mode 100644 e2e/account/login/login.po.js create mode 100644 e2e/account/login/login.spec.js create mode 100644 e2e/account/logout/logout.spec.js create mode 100644 e2e/account/signup/signup.po.js create mode 100644 e2e/account/signup/signup.spec.js create mode 100644 e2e/components/navbar/navbar.po.js create mode 100644 e2e/components/oauth-buttons/oauth-buttons.po.js create mode 100644 e2e/main/main.po.js create mode 100644 e2e/main/main.spec.js create mode 100644 gulpfile.babel.js create mode 100644 karma.conf.js create mode 100644 mocha.conf.js create mode 100644 mocha.global.js create mode 100644 package.json create mode 100644 protractor.conf.js create mode 100644 server/.eslintrc create mode 100644 server/api/ansible/ansible.controller.js create mode 100644 server/api/ansible/ansible.events.js create mode 100644 server/api/ansible/ansible.integration.js create mode 100644 server/api/ansible/ansible.model.js create mode 100644 server/api/ansible/index.js create mode 100644 server/api/ansible/index.spec.js create mode 100644 server/api/custom_module/custom_module.controller.js create mode 100644 server/api/custom_module/custom_module.events.js create mode 100644 server/api/custom_module/custom_module.integration.js create mode 100644 server/api/custom_module/custom_module.model.js create mode 100644 server/api/custom_module/index.js create mode 100644 server/api/custom_module/index.spec.js create mode 100644 server/api/project/index.js create mode 100644 server/api/project/index.spec.js create mode 100644 server/api/project/project.controller.js create mode 100644 server/api/project/project.events.js create mode 100644 server/api/project/project.integration.js create mode 100644 server/api/project/project.model.js create mode 100644 server/api/thing/index.js create mode 100644 server/api/thing/index.spec.js create mode 100644 server/api/thing/thing.controller.js create mode 100644 server/api/thing/thing.events.js create mode 100644 server/api/thing/thing.integration.js create mode 100644 server/api/thing/thing.model.js create mode 100644 server/api/user/index.js create mode 100644 server/api/user/index.spec.js create mode 100644 server/api/user/user.controller.js create mode 100644 server/api/user/user.events.js create mode 100644 server/api/user/user.integration.js create mode 100644 server/api/user/user.model.js create mode 100644 server/api/user/user.model.spec.js create mode 100644 server/app.js create mode 100644 server/auth/auth.service.js create mode 100644 server/auth/facebook/index.js create mode 100644 server/auth/facebook/passport.js create mode 100644 server/auth/google/index.js create mode 100644 server/auth/google/passport.js create mode 100644 server/auth/index.js create mode 100644 server/auth/local/index.js create mode 100644 server/auth/local/passport.js create mode 100644 server/components/ansible/ansible_tool.js create mode 100644 server/components/errors/index.js create mode 100644 server/components/pythonParser.js create mode 100644 server/components/scp/scp_exec.js create mode 100644 server/components/ssh/ssh2_exec.js create mode 100644 server/components/upgrade/upgradetool.js create mode 100644 server/components/utils/dbutility.js create mode 100644 server/config/environment/development.js create mode 100644 server/config/environment/index.js create mode 100644 server/config/environment/production.js create mode 100644 server/config/environment/shared.js create mode 100644 server/config/environment/test.js create mode 100644 server/config/express.js create mode 100644 server/config/local.env.sample.js create mode 100644 server/config/seed.js create mode 100644 server/index.js create mode 100644 server/routes.js create mode 100644 server/views/404.html create mode 100644 spec.js create mode 100644 webpack.build.js create mode 100644 webpack.dev.js create mode 100644 webpack.make.js create mode 100644 webpack.test.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..33f4a0d --- /dev/null +++ b/.babelrc @@ -0,0 +1,6 @@ +{ + "presets": ["es2015"], + "plugins": [ + "transform-class-properties" + ] +} diff --git a/.buildignore b/.buildignore new file mode 100644 index 0000000..e69de29 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c2cdfb8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + + +[*] + +# Change these settings to your own preference +indent_style = space +indent_size = 2 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..28a8da4 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,254 @@ +{ + "parser": "babel-eslint", + "env": { + "es6": true + }, + "globals": {}, + "plugins": [], + "rules": { + //Possible Errors + "comma-dangle": 0, //disallow or enforce trailing commas + "no-cond-assign": 2, //disallow assignment in conditional expressions + "no-console": 0, //disallow use of console in the node environment + "no-constant-condition": 1, //disallow use of constant expressions in conditions + "no-control-regex": 2, //disallow control characters in regular expressions + "no-debugger": 2, //disallow use of debugger + "no-dupe-args": 2, //disallow duplicate arguments in functions + "no-dupe-keys": 2, //disallow duplicate keys when creating object literals + "no-duplicate-case": 0, //disallow a duplicate case label. + "no-empty-character-class": 2, //disallow the use of empty character classes in regular expressions + "no-empty": 2, //disallow empty statements + "no-ex-assign": 2, //disallow assigning to the exception in a catch block + "no-extra-boolean-cast": 2, //disallow double-negation boolean casts in a boolean context + "no-extra-parens": 1, //disallow unnecessary parentheses + "no-extra-semi": 2, //disallow unnecessary semicolons + "no-func-assign": 2, //disallow overwriting functions written as function declarations + "no-inner-declarations": 1, //disallow function or variable declarations in nested blocks + "no-invalid-regexp": 2, //disallow invalid regular expression strings in the RegExp constructor + "no-irregular-whitespace": 2, //disallow irregular whitespace outside of strings and comments + "no-negated-in-lhs": 2, //disallow negation of the left operand of an in expression + "no-obj-calls": 2, //disallow the use of object properties of the global object (Math and JSON) as functions + "no-prototype-builtins": 0, //Disallow use of Object.prototypes builtins directly + "no-regex-spaces": 2, //disallow multiple spaces in a regular expression literal + "no-sparse-arrays": 1, //disallow sparse arrays + "no-unexpected-multiline": 2, //Avoid code that looks like two expressions but is actually one + "no-unreachable": 2, //disallow unreachable statements after a return, throw, continue, or break statement + "no-unsafe-finally": 2, //disallow control flow statements in finally blocks + "use-isnan": 2, //disallow comparisons with the value NaN + "valid-jsdoc": 0, //Ensure JSDoc comments are valid + "valid-typeof": 2, //Ensure that the results of typeof are compared against a valid string + + //Best Practices + "accessor-pairs": 0, //Enforces getter/setter pairs in objects + "array-callback-return": 2, //Enforces return statements in callbacks of array's methods + "block-scoped-var": 0, //treat var statements as if they were block scoped + "complexity": 0, //specify the maximum cyclomatic complexity allowed in a program + "consistent-return": 0, //require return statements to either always or never specify values + "curly": [2, "multi-line"], //specify curly brace conventions for all control statements + "default-case": 0, //require default case in switch statements + "dot-location": [2, "property"], //enforces consistent newlines before or after dots + "dot-notation": 2, //encourages use of dot notation whenever possible + "eqeqeq": 0, //require the use of === and !== + "guard-for-in": 0, //make sure for-in loops have an if statement + "no-alert": 2, //disallow the use of alert, confirm, and prompt + "no-caller": 0, //disallow use of arguments.caller or arguments.callee + "no-case-declarations": 0, //disallow lexical declarations in case clauses + "no-div-regex": 2, //disallow division operators explicitly at beginning of regular expression + "no-else-return": 0, //disallow else after a return in an if + "no-empty-function": 0, //disallow use of empty functions + "no-empty-pattern": 2, //disallow use of empty destructuring patterns + "no-eq-null": 2, //disallow comparisons to null without a type-checking operator + "no-eval": 2, //disallow use of eval() + "no-extend-native": 0, //disallow adding to native types + "no-extra-bind": 1, //disallow unnecessary function binding + "no-extra-label": 2, //disallow unnecessary labels + "no-fallthrough": 0, //disallow fallthrough of case statements + "no-floating-decimal": 0, //disallow the use of leading or trailing decimal points in numeric literals + "no-implicit-coercion": 0, //disallow the type conversions with shorter notations + "no-implicit-globals": 0, //disallow var and named functions in global scope + "no-implied-eval": 2, //disallow use of eval()-like methods + "no-invalid-this": 1, //disallow this keywords outside of classes or class-like objects + "no-iterator": 2, //disallow usage of __iterator__ property + "no-labels": 2, //disallow use of labeled statements + "no-lone-blocks": 2, //disallow unnecessary nested blocks + "no-loop-func": 2, //disallow creation of functions within loops + "no-magic-numbers": 0, //disallow the use of magic numbers + "no-multi-spaces": 2, //disallow use of multiple spaces + "no-multi-str": 0, //disallow use of multiline strings + "no-native-reassign": 2, //disallow reassignments of native objects + "no-new-func": 1, //disallow use of new operator for Function object + "no-new-wrappers": 2, //disallows creating new instances of String,Number, and Boolean + "no-new": 2, //disallow use of the new operator when not part of an assignment or comparison + "no-octal-escape": 0, //disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251"; + "no-octal": 0, //disallow use of octal literals + "no-param-reassign": 0, //disallow reassignment of function parameters + "no-process-env": 0, //disallow use of process.env + "no-proto": 2, //disallow usage of __proto__ property + "no-redeclare": 2, //disallow declaring the same variable more than once + "no-return-assign": 2, //disallow use of assignment in return statement + "no-script-url": 2, //disallow use of javascript: urls. + "no-self-assign": 2, //disallow assignments where both sides are exactly the same + "no-self-compare": 2, //disallow comparisons where both sides are exactly the same + "no-sequences": 2, //disallow use of the comma operator + "no-throw-literal": 2, //restrict what can be thrown as an exception + "no-unmodified-loop-condition": 0, //disallow unmodified conditions of loops + "no-unused-expressions": 0, //disallow usage of expressions in statement position + "no-unused-labels": 2, //disallow unused labels + "no-useless-call": 2, //disallow unnecessary .call() and .apply() + "no-useless-concat": 2, //disallow unnecessary concatenation of literals or template literals + "no-useless-escape": 2, //disallow unnecessary escape characters + "no-void": 0, //disallow use of the void operator + "no-warning-comments": 1, //disallow usage of configurable warning terms in comments (e.g. TODO or FIXME) + "no-with": 2, //disallow use of the with statement + "radix": 2, //require use of the second argument for parseInt() + "vars-on-top": 0, //require declaration of all vars at the top of their containing scope + "wrap-iife": 2, //require immediate function invocation to be wrapped in parentheses + "yoda": 2, //require or disallow Yoda conditions + + //Strict Mode + "strict": 0, //controls location of Use Strict Directives + + //Variables + "init-declarations": 0, //enforce or disallow variable initializations at definition + "no-catch-shadow": 2, //disallow the catch clause parameter name being the same as a variable in the outer scope + "no-delete-var": 2, //disallow deletion of variables + "no-label-var": 2, //disallow labels that share a name with a variable + "no-restricted-globals": 0, //restrict usage of specified global variables + "no-shadow-restricted-names": 2, //disallow shadowing of names such as arguments + "no-shadow": [2, {"allow": ["err"]}], //disallow declaration of variables already declared in the outer scope + "no-undef-init": 2, //disallow use of undefined when initializing variables + "no-undef": 2, //disallow use of undeclared variables unless mentioned in a /*global */ block + "no-undefined": 0, //disallow use of undefined variable + "no-unused-vars": 2, //disallow declaration of variables that are not used in the code + "no-use-before-define": 0, //disallow use of variables before they are defined + + //Node.js and CommonJS + "callback-return": 2, //enforce return after a callback + "global-require": 0, //enforce require() on top-level module scope + "handle-callback-err": 2, //enforce error handling in callbacks + "no-mixed-requires": 2, //disallow mixing regular variable and require declarations + "no-new-require": 2, //disallow use of new operator with the require function + "no-path-concat": 2, //disallow string concatenation with __dirname and __filename + "no-process-exit": 2, //disallow process.exit() + "no-restricted-imports": 0, //restrict usage of specified node imports + "no-restricted-modules": 0, //restrict usage of specified node modules + "no-sync": 1, //disallow use of synchronous methods + + //Stylistic Issues + "array-bracket-spacing": [2, "never"], //enforce spacing inside array brackets + "block-spacing": 0, //disallow or enforce spaces inside of single line blocks + "brace-style": 2, //enforce one true brace style + "camelcase": 1, //require camel case names + "comma-spacing": [2, {"before": false, "after": true}], //enforce spacing before and after comma + "comma-style": 2, //enforce one true comma style + "computed-property-spacing": 2, //require or disallow padding inside computed properties + "consistent-this": 2, //enforce consistent naming when capturing the current execution context + "eol-last": 2, //enforce newline at the end of file, with no multiple empty lines + "func-names": 0, //require function expressions to have a name + "func-style": 0, //enforce use of function declarations or expressions + "id-blacklist": 0, //blacklist certain identifiers to prevent them being used + "id-length": 0, //this option enforces minimum and maximum identifier lengths (variable names, property names etc.) + "id-match": 0, //require identifiers to match the provided regular expression + "indent": ["error", 2], //specify tab or space width for your code + "jsx-quotes": 0, //specify whether double or single quotes should be used in JSX attributes + "key-spacing": 2, //enforce spacing between keys and values in object literal properties + "keyword-spacing": [2, { + "before": true, + "after": true, + "overrides": { + "if": {"after": false}, + "for": {"after": false}, + "while": {"after": false}, + "catch": {"after": false} + } + }], //enforce spacing before and after keywords + "linebreak-style": 2, //disallow mixed 'LF' and 'CRLF' as linebreaks + "lines-around-comment": 0, //enforce empty lines around comments + "max-depth": 1, //specify the maximum depth that blocks can be nested + "max-len": [1, 200], //specify the maximum length of a line in your program + "max-lines": 0, //enforce a maximum file length + "max-nested-callbacks": 2, //specify the maximum depth callbacks can be nested + "max-params": 0, //limits the number of parameters that can be used in the function declaration. + "max-statements": 0, //specify the maximum number of statement allowed in a function + "max-statements-per-line": 0, //enforce a maximum number of statements allowed per line + "new-cap": 0, //require a capital letter for constructors + "new-parens": 2, //disallow the omission of parentheses when invoking a constructor with no arguments + "newline-after-var": 0, //require or disallow an empty newline after variable declarations + "newline-before-return": 0, //require newline before return statement + "newline-per-chained-call": [ + "error", + {"ignoreChainWithDepth": 2} + ], //enforce newline after each call when chaining the calls + "no-array-constructor": 2, //disallow use of the Array constructor + "no-bitwise": 0, //disallow use of bitwise operators + "no-continue": 0, //disallow use of the continue statement + "no-inline-comments": 0, //disallow comments inline after code + "no-lonely-if": 2, //disallow if as the only statement in an else block + "no-mixed-operators": 0, //disallow mixes of different operators + "no-mixed-spaces-and-tabs": 2, //disallow mixed spaces and tabs for indentation + "no-multiple-empty-lines": 2, //disallow multiple empty lines + "no-negated-condition": 0, //disallow negated conditions + "no-nested-ternary": 0, //disallow nested ternary expressions + "no-new-object": 2, //disallow the use of the Object constructor + "no-plusplus": 0, //disallow use of unary operators, ++ and -- + "no-restricted-syntax": 0, //disallow use of certain syntax in code + "no-spaced-func": 2, //disallow space between function identifier and application + "no-ternary": 0, //disallow the use of ternary operators + "no-trailing-spaces": 2, //disallow trailing whitespace at the end of lines + "no-underscore-dangle": 0, //disallow dangling underscores in identifiers + "no-unneeded-ternary": 2, //disallow the use of ternary operators when a simpler alternative exists + "no-whitespace-before-property": 2, //disallow whitespace before properties + "object-curly-newline": 0, //enforce consistent line breaks inside braces + "object-curly-spacing": 0, //require or disallow padding inside curly braces + "object-property-newline": 0, //enforce placing object properties on separate lines + "one-var": [2, "never"], //require or disallow one variable declaration per function + "one-var-declaration-per-line": 2, //require or disallow an newline around variable declarations + "operator-assignment": 0, //require assignment operator shorthand where possible or prohibit it entirely + "operator-linebreak": [1, "before"], //enforce operators to be placed before or after line breaks + "padded-blocks": [2, "never"], //enforce padding within blocks + "quote-props": [2, "as-needed"], //require quotes around object literal property names + "quotes": [2, "single"], //specify whether backticks, double or single quotes should be used + "require-jsdoc": 0, //Require JSDoc comment + "semi-spacing": 2, //enforce spacing before and after semicolons + "sort-imports": 0, //sort import declarations within module + "semi": 2, //require or disallow use of semicolons instead of ASI + "sort-vars": 0, //sort variables within the same declaration block + "space-before-blocks": 2, //require or disallow a space before blocks + "space-before-function-paren": [2, "never"], //require or disallow a space before function opening parenthesis + "space-in-parens": 2, //require or disallow spaces inside parentheses + "space-infix-ops": 2, //require spaces around operators + "space-unary-ops": 2, //require or disallow spaces before/after unary operators + "spaced-comment": 0, //require or disallow a space immediately following the // or /* in a comment + "unicode-bom": 0, //require or disallow the Unicode BOM + "wrap-regex": 0, //require regex literals to be wrapped in parentheses + + //ECMAScript 6 + "arrow-body-style": [2, "as-needed"], //require braces in arrow function body + "arrow-parens": [2, "as-needed"], //require parens in arrow function arguments + "arrow-spacing": 2, //require space before/after arrow function's arrow + "constructor-super": 2, //verify calls of super() in constructors + "generator-star-spacing": 0, //enforce spacing around the * in generator functions + "no-class-assign": 2, //disallow modifying variables of class declarations + "no-confusing-arrow": 2, //disallow arrow functions where they could be confused with comparisons + "no-const-assign": 2, //disallow modifying variables that are declared using const + "no-dupe-class-members": 2, //disallow duplicate name in class members + "no-duplicate-imports": 2, //disallow duplicate module imports + "no-new-symbol": 2, //disallow use of the new operator with the Symbol object + "no-this-before-super": 2, //disallow use of this/super before calling super() in constructors. + "no-useless-computed-key": 2, //disallow unnecessary computed property keys in object literals + "no-useless-constructor": 2, //disallow unnecessary constructor + "no-useless-rename": 2, //disallow renaming import, export, and destructured assignments to the same name + "no-var": 0, //require let or const instead of var + "object-shorthand": 1, //require method and property shorthand syntax for object literals + "prefer-arrow-callback": 0, //suggest using arrow functions as callbacks + "prefer-const": 0, //suggest using const declaration for variables that are never modified after declared + "prefer-reflect": 1, //suggest using Reflect methods where applicable + "prefer-rest-params": 1, //suggest using the rest parameters instead of arguments + "prefer-spread": 1, //suggest using the spread operator instead of .apply(). + "prefer-template": 1, //suggest using template literals instead of strings concatenation + "require-yield": 2, //disallow generator functions that do not have yield + "rest-spread-spacing": ["error", "never"], //enforce spacing between rest and spread operators and their expressions + "template-curly-spacing": 2, //enforce spacing around embedded expressions of template strings + "yield-star-spacing": [2, "after"] //enforce spacing around the * in yield* expressions + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d9a5563 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,43 @@ +* text=auto + +# These files are text and should be normalized (Convert crlf => lf) +*.php text +*.css text +*.js text +*.htm text +*.html text +*.xml text +*.txt text +*.ini text +*.inc text +.htaccess text + +# Denote all files that are truly binary and should not be modified. +# (binary is a macro for -text -diff) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mov binary +*.mp4 binary +*.mp3 binary +*.flv binary +*.fla binary +*.swf binary +*.gz binary +*.zip binary +*.7z binary +*.ttf binary + +# Documents +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c79b36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +public +.tmp +.idea +client/bower_components +client/index.html +dist +/server/config/local.env.js +npm-debug.log +coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..506cd16 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: node_js +node_js: + - 6 +matrix: + fast_finish: true + allow_failures: + - node_js: 5.12.0 +before_script: + - npm install -g gulp-cli node-gyp +services: mongodb +cache: + directories: + - node_modules +env: + - CXX=g++-4.8 +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..163881f --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,72 @@ +{ + "generator-angular-fullstack": { + "generatorVersion": "4.2.2", + "endpointDirectory": "server/api/", + "insertRoutes": true, + "registerRoutesFile": "server/routes.js", + "routesNeedle": "// Insert routes below", + "routesBase": "/api/", + "pluralizeRoutes": true, + "insertSockets": true, + "registerSocketsFile": "server/config/socketio.js", + "socketsNeedle": "// Insert sockets below", + "insertModels": true, + "registerModelsFile": "server/sqldb/index.js", + "modelsNeedle": "// Insert models below", + "filters": { + "js": true, + "babel": true, + "flow": false, + "html": true, + "css": true, + "uirouter": true, + "bootstrap": true, + "uibootstrap": true, + "auth": true, + "models": true, + "mongooseModels": true, + "mongoose": true, + "oauth": true, + "googleAuth": true, + "facebookAuth": true, + "mocha": true, + "jasmine": false, + "should": false, + "expect": true + } + }, + "generator-ng-component": { + "routeDirectory": "client/app/", + "directiveDirectory": "client/app/", + "componentDirectory": "client/app/components/", + "filterDirectory": "client/app/", + "serviceDirectory": "client/app/", + "basePath": "client", + "moduleName": "", + "modulePrompt": true, + "filters": [ + "uirouter", + "mocha", + "expect", + "uirouter", + "es6", + "webpack" + ], + "extensions": [ + "babel", + "js", + "html", + "css" + ], + "directiveSimpleTemplates": "", + "directiveComplexTemplates": "", + "filterTemplates": "", + "serviceTemplates": "", + "factoryTemplates": "", + "controllerTemplates": "", + "componentTemplates": "", + "decoratorTemplates": "", + "providerTemplates": "", + "routeTemplates": "" + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46b0769 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Pull base image. +FROM node:6.2.2 + +# Reset Root Password +RUN echo "root:P@ssw0rd@123" | chpasswd + +# Install Ansible +RUN apt-get update && \ + apt-get install python-setuptools python-dev build-essential -y && \ + easy_install pip && \ + pip install ansible + +# TO fix a bug +RUN mkdir -p /root/.config/configstore && chmod g+rwx /root /root/.config /root/.config/configstore +RUN useradd -u 1003 -d /home/app_user -m -s /bin/bash -p $(echo P@ssw0rd@123 | openssl passwd -1 -stdin) app_user + +# Create data directory +RUN mkdir -p /data + +RUN chown -R app_user /usr/local && chown -R app_user /home/app_user && chown -R app_user /data + +# Install VIM and Openssh-Server +RUN apt-get update && apt-get install -y vim openssh-server + +# Permit Root login +RUN sed -i '/PermitRootLogin */cPermitRootLogin yes' /etc/ssh/sshd_config + +# Generate SSH Keys +RUN /usr/bin/ssh-keygen -A + +# Start Open-ssh server +RUN service ssh start + +# Change user to app_user +USER app_user + +RUN mkdir -p /data/web-app +COPY * /data/web-app + +USER root +RUN chown -R app_user /data/web-app + +USER app_user +WORKDIR /data/web-app + +RUN npm install -g yo gulp-cli generator-angular-fullstack +RUN npm install + +ENTRYPOINT gulp serve diff --git a/README.md b/README.md new file mode 100644 index 0000000..b31f6d2 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# app2 + +This project was generated with the [Angular Full-Stack Generator](https://github.com/DaftMonk/generator-angular-fullstack) version 4.2.2. + +## Getting Started + +### Prerequisites + +- [Git](https://git-scm.com/) +- [Node.js and npm](nodejs.org) Node >= 4.x.x, npm >= 2.x.x +- [Gulp](http://gulpjs.com/) (`npm install --global gulp`) +- [MongoDB](https://www.mongodb.org/) - Keep a running daemon with `mongod` + +### Developing + +1. Run `npm install` to install server dependencies. + +2. Run `mongod` in a separate shell to keep an instance of the MongoDB Daemon running + +3. Run `gulp serve` to start the development server. It should automatically open the client in your browser when ready. + +## Build & development + +Run `gulp build` for building and `gulp serve` for preview. + +## Testing + +Running `npm test` will run the unit tests with karma. diff --git a/client/.eslintrc b/client/.eslintrc new file mode 100644 index 0000000..b12322f --- /dev/null +++ b/client/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": "../.eslintrc", + "env": { + "browser": true, + "commonjs": true + } +} diff --git a/client/.htaccess b/client/.htaccess new file mode 100644 index 0000000..cb84cb9 --- /dev/null +++ b/client/.htaccess @@ -0,0 +1,543 @@ +# Apache Configuration File + +# (!) Using `.htaccess` files slows down Apache, therefore, if you have access +# to the main server config file (usually called `httpd.conf`), you should add +# this logic there: http://httpd.apache.org/docs/current/howto/htaccess.html. + +# ############################################################################## +# # CROSS-ORIGIN RESOURCE SHARING (CORS) # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Cross-domain AJAX requests | +# ------------------------------------------------------------------------------ + +# Enable cross-origin AJAX requests. +# http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity +# http://enable-cors.org/ + +# +# Header set Access-Control-Allow-Origin "*" +# + +# ------------------------------------------------------------------------------ +# | CORS-enabled images | +# ------------------------------------------------------------------------------ + +# Send the CORS header for images when browsers request it. +# https://developer.mozilla.org/en/CORS_Enabled_Image +# http://blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html +# http://hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/ + + + + + SetEnvIf Origin ":" IS_CORS + Header set Access-Control-Allow-Origin "*" env=IS_CORS + + + + +# ------------------------------------------------------------------------------ +# | Web fonts access | +# ------------------------------------------------------------------------------ + +# Allow access from all domains for web fonts + + + + Header set Access-Control-Allow-Origin "*" + + + + +# ############################################################################## +# # ERRORS # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | 404 error prevention for non-existing redirected folders | +# ------------------------------------------------------------------------------ + +# Prevent Apache from returning a 404 error for a rewrite if a directory +# with the same name does not exist. +# http://httpd.apache.org/docs/current/content-negotiation.html#multiviews +# http://www.webmasterworld.com/apache/3808792.htm + +Options -MultiViews + +# ------------------------------------------------------------------------------ +# | Custom error messages / pages | +# ------------------------------------------------------------------------------ + +# You can customize what Apache returns to the client in case of an error (see +# http://httpd.apache.org/docs/current/mod/core.html#errordocument), e.g.: + +ErrorDocument 404 /404.html + + +# ############################################################################## +# # INTERNET EXPLORER # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Better website experience | +# ------------------------------------------------------------------------------ + +# Force IE to render pages in the highest available mode in the various +# cases when it may not: http://hsivonen.iki.fi/doctype/ie-mode.pdf. + + + Header set X-UA-Compatible "IE=edge" + # `mod_headers` can't match based on the content-type, however, we only + # want to send this header for HTML pages and not for the other resources + + Header unset X-UA-Compatible + + + +# ------------------------------------------------------------------------------ +# | Cookie setting from iframes | +# ------------------------------------------------------------------------------ + +# Allow cookies to be set from iframes in IE. + +# +# Header set P3P "policyref=\"/w3c/p3p.xml\", CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\"" +# + +# ------------------------------------------------------------------------------ +# | Screen flicker | +# ------------------------------------------------------------------------------ + +# Stop screen flicker in IE on CSS rollovers (this only works in +# combination with the `ExpiresByType` directives for images from below). + +# BrowserMatch "MSIE" brokenvary=1 +# BrowserMatch "Mozilla/4.[0-9]{2}" brokenvary=1 +# BrowserMatch "Opera" !brokenvary +# SetEnvIf brokenvary 1 force-no-vary + + +# ############################################################################## +# # MIME TYPES AND ENCODING # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Proper MIME types for all files | +# ------------------------------------------------------------------------------ + + + + # Audio + AddType audio/mp4 m4a f4a f4b + AddType audio/ogg oga ogg + + # JavaScript + # Normalize to standard type (it's sniffed in IE anyways): + # http://tools.ietf.org/html/rfc4329#section-7.2 + AddType application/javascript js jsonp + AddType application/json json + + # Video + AddType video/mp4 mp4 m4v f4v f4p + AddType video/ogg ogv + AddType video/webm webm + AddType video/x-flv flv + + # Web fonts + AddType application/font-woff woff + AddType application/vnd.ms-fontobject eot + + # Browsers usually ignore the font MIME types and sniff the content, + # however, Chrome shows a warning if other MIME types are used for the + # following fonts. + AddType application/x-font-ttf ttc ttf + AddType font/opentype otf + + # Make SVGZ fonts work on iPad: + # https://twitter.com/FontSquirrel/status/14855840545 + AddType image/svg+xml svg svgz + AddEncoding gzip svgz + + # Other + AddType application/octet-stream safariextz + AddType application/x-chrome-extension crx + AddType application/x-opera-extension oex + AddType application/x-shockwave-flash swf + AddType application/x-web-app-manifest+json webapp + AddType application/x-xpinstall xpi + AddType application/xml atom rdf rss xml + AddType image/webp webp + AddType image/x-icon ico + AddType text/cache-manifest appcache manifest + AddType text/vtt vtt + AddType text/x-component htc + AddType text/x-vcard vcf + + + +# ------------------------------------------------------------------------------ +# | UTF-8 encoding | +# ------------------------------------------------------------------------------ + +# Use UTF-8 encoding for anything served as `text/html` or `text/plain`. +AddDefaultCharset utf-8 + +# Force UTF-8 for certain file formats. + + AddCharset utf-8 .atom .css .js .json .rss .vtt .webapp .xml + + + +# ############################################################################## +# # URL REWRITES # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Rewrite engine | +# ------------------------------------------------------------------------------ + +# Turning on the rewrite engine and enabling the `FollowSymLinks` option is +# necessary for the following directives to work. + +# If your web host doesn't allow the `FollowSymlinks` option, you may need to +# comment it out and use `Options +SymLinksIfOwnerMatch` but, be aware of the +# performance impact: http://httpd.apache.org/docs/current/misc/perf-tuning.html#symlinks + +# Also, some cloud hosting services require `RewriteBase` to be set: +# http://www.rackspace.com/knowledge_center/frequently-asked-question/why-is-mod-rewrite-not-working-on-my-site + + + Options +FollowSymlinks + # Options +SymLinksIfOwnerMatch + RewriteEngine On + # RewriteBase / + + +# ------------------------------------------------------------------------------ +# | Suppressing / Forcing the "www." at the beginning of URLs | +# ------------------------------------------------------------------------------ + +# The same content should never be available under two different URLs especially +# not with and without "www." at the beginning. This can cause SEO problems +# (duplicate content), therefore, you should choose one of the alternatives and +# redirect the other one. + +# By default option 1 (no "www.") is activated: +# http://no-www.org/faq.php?q=class_b + +# If you'd prefer to use option 2, just comment out all the lines from option 1 +# and uncomment the ones from option 2. + +# IMPORTANT: NEVER USE BOTH RULES AT THE SAME TIME! + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Option 1: rewrite www.example.com → example.com + + + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Option 2: rewrite example.com → www.example.com + +# Be aware that the following might not be a good idea if you use "real" +# subdomains for certain parts of your website. + +# +# RewriteCond %{HTTPS} !=on +# RewriteCond %{HTTP_HOST} !^www\..+$ [NC] +# RewriteRule ^ http://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L] +# + + +# ############################################################################## +# # SECURITY # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Content Security Policy (CSP) | +# ------------------------------------------------------------------------------ + +# You can mitigate the risk of cross-site scripting and other content-injection +# attacks by setting a Content Security Policy which whitelists trusted sources +# of content for your site. + +# The example header below allows ONLY scripts that are loaded from the current +# site's origin (no inline scripts, no CDN, etc). This almost certainly won't +# work as-is for your site! + +# To get all the details you'll need to craft a reasonable policy for your site, +# read: http://html5rocks.com/en/tutorials/security/content-security-policy (or +# see the specification: http://w3.org/TR/CSP). + +# +# Header set Content-Security-Policy "script-src 'self'; object-src 'self'" +# +# Header unset Content-Security-Policy +# +# + +# ------------------------------------------------------------------------------ +# | File access | +# ------------------------------------------------------------------------------ + +# Block access to directories without a default document. +# Usually you should leave this uncommented because you shouldn't allow anyone +# to surf through every directory on your server (which may includes rather +# private places like the CMS's directories). + + + Options -Indexes + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Block access to hidden files and directories. +# This includes directories used by version control systems such as Git and SVN. + + + RewriteCond %{SCRIPT_FILENAME} -d [OR] + RewriteCond %{SCRIPT_FILENAME} -f + RewriteRule "(^|/)\." - [F] + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Block access to backup and source files. +# These files may be left by some text editors and can pose a great security +# danger when anyone has access to them. + + + Order allow,deny + Deny from all + Satisfy All + + +# ------------------------------------------------------------------------------ +# | Secure Sockets Layer (SSL) | +# ------------------------------------------------------------------------------ + +# Rewrite secure requests properly to prevent SSL certificate warnings, e.g.: +# prevent `https://www.example.com` when your certificate only allows +# `https://secure.example.com`. + +# +# RewriteCond %{SERVER_PORT} !^443 +# RewriteRule ^ https://example-domain-please-change-me.com%{REQUEST_URI} [R=301,L] +# + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Force client-side SSL redirection. + +# If a user types "example.com" in his browser, the above rule will redirect him +# to the secure version of the site. That still leaves a window of opportunity +# (the initial HTTP connection) for an attacker to downgrade or redirect the +# request. The following header ensures that browser will ONLY connect to your +# server via HTTPS, regardless of what the users type in the address bar. +# http://www.html5rocks.com/en/tutorials/security/transport-layer-security/ + +# +# Header set Strict-Transport-Security max-age=16070400; +# + +# ------------------------------------------------------------------------------ +# | Server software information | +# ------------------------------------------------------------------------------ + +# Avoid displaying the exact Apache version number, the description of the +# generic OS-type and the information about Apache's compiled-in modules. + +# ADD THIS DIRECTIVE IN THE `httpd.conf` AS IT WILL NOT WORK IN THE `.htaccess`! + +# ServerTokens Prod + + +# ############################################################################## +# # WEB PERFORMANCE # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Compression | +# ------------------------------------------------------------------------------ + + + + # Force compression for mangled headers. + # http://developer.yahoo.com/blogs/ydn/posts/2010/12/pushing-beyond-gzipping + + + SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding + RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding + + + + # Compress all output labeled with one of the following MIME-types + # (for Apache versions below 2.3.7, you don't need to enable `mod_filter` + # and can remove the `` and `` lines + # as `AddOutputFilterByType` is still in the core directives). + + AddOutputFilterByType DEFLATE application/atom+xml \ + application/javascript \ + application/json \ + application/rss+xml \ + application/vnd.ms-fontobject \ + application/x-font-ttf \ + application/x-web-app-manifest+json \ + application/xhtml+xml \ + application/xml \ + font/opentype \ + image/svg+xml \ + image/x-icon \ + text/css \ + text/html \ + text/plain \ + text/x-component \ + text/xml + + + + +# ------------------------------------------------------------------------------ +# | Content transformations | +# ------------------------------------------------------------------------------ + +# Prevent some of the mobile network providers from modifying the content of +# your site: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.5. + +# +# Header set Cache-Control "no-transform" +# + +# ------------------------------------------------------------------------------ +# | ETag removal | +# ------------------------------------------------------------------------------ + +# Since we're sending far-future expires headers (see below), ETags can +# be removed: http://developer.yahoo.com/performance/rules.html#etags. + +# `FileETag None` is not enough for every server. + + Header unset ETag + + +FileETag None + +# ------------------------------------------------------------------------------ +# | Expires headers (for better cache control) | +# ------------------------------------------------------------------------------ + +# The following expires headers are set pretty far in the future. If you don't +# control versioning with filename-based cache busting, consider lowering the +# cache time for resources like CSS and JS to something like 1 week. + + + + ExpiresActive on + ExpiresDefault "access plus 1 month" + + # CSS + ExpiresByType text/css "access plus 1 year" + + # Data interchange + ExpiresByType application/json "access plus 0 seconds" + ExpiresByType application/xml "access plus 0 seconds" + ExpiresByType text/xml "access plus 0 seconds" + + # Favicon (cannot be renamed!) + ExpiresByType image/x-icon "access plus 1 week" + + # HTML components (HTCs) + ExpiresByType text/x-component "access plus 1 month" + + # HTML + ExpiresByType text/html "access plus 0 seconds" + + # JavaScript + ExpiresByType application/javascript "access plus 1 year" + + # Manifest files + ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds" + ExpiresByType text/cache-manifest "access plus 0 seconds" + + # Media + ExpiresByType audio/ogg "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType video/mp4 "access plus 1 month" + ExpiresByType video/ogg "access plus 1 month" + ExpiresByType video/webm "access plus 1 month" + + # Web feeds + ExpiresByType application/atom+xml "access plus 1 hour" + ExpiresByType application/rss+xml "access plus 1 hour" + + # Web fonts + ExpiresByType application/font-woff "access plus 1 month" + ExpiresByType application/vnd.ms-fontobject "access plus 1 month" + ExpiresByType application/x-font-ttf "access plus 1 month" + ExpiresByType font/opentype "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + +# ------------------------------------------------------------------------------ +# | Filename-based cache busting | +# ------------------------------------------------------------------------------ + +# If you're not using a build process to manage your filename version revving, +# you might want to consider enabling the following directives to route all +# requests such as `/css/style.12345.css` to `/css/style.css`. + +# To understand why this is important and a better idea than `*.css?v231`, read: +# http://stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring + +# +# RewriteCond %{REQUEST_FILENAME} !-f +# RewriteCond %{REQUEST_FILENAME} !-d +# RewriteRule ^(.+)\.(\d+)\.(js|css|png|jpg|gif)$ $1.$3 [L] +# + +# ------------------------------------------------------------------------------ +# | File concatenation | +# ------------------------------------------------------------------------------ + +# Allow concatenation from within specific CSS and JS files, e.g.: +# Inside of `script.combined.js` you could have +# +# +# and they would be included into this single file. + +# +# +# Options +Includes +# AddOutputFilterByType INCLUDES application/javascript application/json +# SetOutputFilter INCLUDES +# +# +# Options +Includes +# AddOutputFilterByType INCLUDES text/css +# SetOutputFilter INCLUDES +# +# + +# ------------------------------------------------------------------------------ +# | Persistent connections | +# ------------------------------------------------------------------------------ + +# Allow multiple requests to be sent over the same TCP connection: +# http://httpd.apache.org/docs/current/en/mod/core.html#keepalive. + +# Enable if you serve a lot of static content but, be aware of the +# possible disadvantages! + +# +# Header set Connection Keep-Alive +# diff --git a/client/_index.html b/client/_index.html new file mode 100644 index 0000000..7dfddd8 --- /dev/null +++ b/client/_index.html @@ -0,0 +1,36 @@ + + + + + + + Angular Full-Stack Generator + + + + + + + + + + + + + + + +
+ + + diff --git a/client/app/account/account.routes.js b/client/app/account/account.routes.js new file mode 100644 index 0000000..0370ed2 --- /dev/null +++ b/client/app/account/account.routes.js @@ -0,0 +1,37 @@ +'use strict'; + +export default function routes($stateProvider) { + 'ngInject'; + + $stateProvider.state('login', { + url: '/login', + template: require('./login/login.html'), + controller: 'LoginController', + controllerAs: 'vm' + }) + .state('logout', { + url: '/logout?referrer', + referrer: 'main', + template: '', + controller($state, Auth) { + 'ngInject'; + + var referrer = $state.params.referrer || $state.current.referrer || 'main'; + Auth.logout(); + $state.go(referrer); + } + }) + .state('signup', { + url: '/signup', + template: require('./signup/signup.html'), + controller: 'SignupController', + controllerAs: 'vm' + }) + .state('settings', { + url: '/settings', + template: require('./settings/settings.html'), + controller: 'SettingsController', + controllerAs: 'vm', + authenticate: true + }); +} diff --git a/client/app/account/index.js b/client/app/account/index.js new file mode 100644 index 0000000..032b1e4 --- /dev/null +++ b/client/app/account/index.js @@ -0,0 +1,24 @@ +'use strict'; + +import angular from 'angular'; + +import uiRouter from 'angular-ui-router'; + +import routing from './account.routes'; +import login from './login'; +import settings from './settings'; +import signup from './signup'; +import oauthButtons from '../../components/oauth-buttons'; + +export default angular.module('app2App.account', [uiRouter, login, settings, signup, oauthButtons]) + .config(routing) + .run(function($rootScope) { + 'ngInject'; + + $rootScope.$on('$stateChangeStart', function(event, next, nextParams, current) { + if(next.name === 'logout' && current && current.name && !current.authenticate) { + next.referrer = current.name; + } + }); + }) + .name; diff --git a/client/app/account/login/index.js b/client/app/account/login/index.js new file mode 100644 index 0000000..ec01fab --- /dev/null +++ b/client/app/account/login/index.js @@ -0,0 +1,8 @@ +'use strict'; + +import angular from 'angular'; +import LoginController from './login.controller'; + +export default angular.module('app2App.login', []) + .controller('LoginController', LoginController) + .name; diff --git a/client/app/account/login/login.controller.js b/client/app/account/login/login.controller.js new file mode 100644 index 0000000..c66a93c --- /dev/null +++ b/client/app/account/login/login.controller.js @@ -0,0 +1,38 @@ +'use strict'; + +export default class LoginController { + user = { + name: '', + email: '', + password: '' + }; + errors = { + login: undefined + }; + submitted = false; + + + /*@ngInject*/ + constructor(Auth, $state) { + this.Auth = Auth; + this.$state = $state; + } + + login(form) { + this.submitted = true; + + if(form.$valid) { + this.Auth.login({ + email: this.user.email, + password: this.user.password + }) + .then(() => { + // Logged in, redirect to home + this.$state.go('main'); + }) + .catch(err => { + this.errors.login = err.message; + }); + } + } +} diff --git a/client/app/account/login/login.html b/client/app/account/login/login.html new file mode 100644 index 0000000..f595f95 --- /dev/null +++ b/client/app/account/login/login.html @@ -0,0 +1,53 @@ +
+
+
+

Login

+

Accounts are reset on server restart from server/config/seed.js. Default account is test@example.com / test

+

Admin account is admin@example.com / admin

+
+
+
+ +
+ + + +
+ +
+ + + +
+ +
+

+ Please enter your email and password. +

+

+ Please enter a valid email. +

+ +

{{ vm.errors.login }}

+
+ +
+ + + Register + +
+ +
+
+
+ +
+
+
+
+
+
+
diff --git a/client/app/account/settings/index.js b/client/app/account/settings/index.js new file mode 100644 index 0000000..42dd6de --- /dev/null +++ b/client/app/account/settings/index.js @@ -0,0 +1,8 @@ +'use strict'; + +import angular from 'angular'; +import SettingsController from './settings.controller'; + +export default angular.module('app2App.settings', []) + .controller('SettingsController', SettingsController) + .name; diff --git a/client/app/account/settings/settings.controller.js b/client/app/account/settings/settings.controller.js new file mode 100644 index 0000000..cada554 --- /dev/null +++ b/client/app/account/settings/settings.controller.js @@ -0,0 +1,36 @@ +'use strict'; + +export default class SettingsController { + user = { + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + errors = { + other: undefined + }; + message = ''; + submitted = false; + + + /*@ngInject*/ + constructor(Auth) { + this.Auth = Auth; + } + + changePassword(form) { + this.submitted = true; + + if(form.$valid) { + this.Auth.changePassword(this.user.oldPassword, this.user.newPassword) + .then(() => { + this.message = 'Password successfully changed.'; + }) + .catch(() => { + form.password.$setValidity('mongoose', false); + this.errors.other = 'Incorrect password'; + this.message = ''; + }); + } + } +} diff --git a/client/app/account/settings/settings.html b/client/app/account/settings/settings.html new file mode 100644 index 0000000..690b0cf --- /dev/null +++ b/client/app/account/settings/settings.html @@ -0,0 +1,51 @@ +
+
+
+

Change Password

+
+
+
+ +
+ + + +

+ {{ vm.errors.other }} +

+
+ +
+ + + +

+ Password must be at least 3 characters. +

+
+ +
+ + + +

+ Passwords must match. +

+ +
+ +

{{ vm.message }}

+ + +
+
+
+
diff --git a/client/app/account/signup/index.js b/client/app/account/signup/index.js new file mode 100644 index 0000000..b967e18 --- /dev/null +++ b/client/app/account/signup/index.js @@ -0,0 +1,8 @@ +'use strict'; + +import angular from 'angular'; +import SignupController from './signup.controller'; + +export default angular.module('app2App.signup', []) + .controller('SignupController', SignupController) + .name; diff --git a/client/app/account/signup/signup.controller.js b/client/app/account/signup/signup.controller.js new file mode 100644 index 0000000..0ee9f68 --- /dev/null +++ b/client/app/account/signup/signup.controller.js @@ -0,0 +1,45 @@ +'use strict'; + +import angular from 'angular'; + +export default class SignupController { + user = { + name: '', + email: '', + password: '' + }; + errors = {}; + submitted = false; + + + /*@ngInject*/ + constructor(Auth, $state) { + this.Auth = Auth; + this.$state = $state; + } + + register(form) { + this.submitted = true; + + if(form.$valid) { + return this.Auth.createUser({ + name: this.user.name, + email: this.user.email, + password: this.user.password + }) + .then(() => { + // Account created, redirect to home + this.$state.go('main'); + }) + .catch(err => { + err = err.data; + this.errors = {}; + // Update validity of form fields that match the mongoose errors + angular.forEach(err.errors, (error, field) => { + form[field].$setValidity('mongoose', false); + this.errors[field] = error.message; + }); + }); + } + } +} diff --git a/client/app/account/signup/signup.html b/client/app/account/signup/signup.html new file mode 100644 index 0000000..962ea62 --- /dev/null +++ b/client/app/account/signup/signup.html @@ -0,0 +1,86 @@ +
+
+
+

Sign up

+
+
+
+ +
+ + + +

+ A name is required +

+
+ +
+ + + +

+ Doesn't look like a valid email. +

+

+ What's your email address? +

+

+ {{ vm.errors.email }} +

+
+ +
+ + + +

+ Password must be at least 3 characters. +

+

+ {{ vm.errors.password }} +

+
+ +
+ + +

+ Passwords must match. +

+
+ +
+ + +
+ +
+
+
+ +
+
+
+
+
+
+
diff --git a/client/app/admin/admin.controller.js b/client/app/admin/admin.controller.js new file mode 100644 index 0000000..c2b47d9 --- /dev/null +++ b/client/app/admin/admin.controller.js @@ -0,0 +1,14 @@ +'use strict'; + +export default class AdminController { + /*@ngInject*/ + constructor(User) { + // Use the User $resource to fetch all users + this.users = User.query(); + } + + delete(user) { + user.$remove(); + this.users.splice(this.users.indexOf(user), 1); + } +} diff --git a/client/app/admin/admin.css b/client/app/admin/admin.css new file mode 100644 index 0000000..3925cbe --- /dev/null +++ b/client/app/admin/admin.css @@ -0,0 +1,19 @@ +.trash { color:rgb(209, 91, 71); } + +.user-list li { + display: flex; + border: none; + border-bottom: 1px lightgray solid; + margin-bottom: 0; +} +.user-list li:last-child { + border-bottom: none; +} +.user-list li .user-info { + flex-grow: 1; +} +.user-list li .trash { + display: flex; + align-items: center; + text-decoration: none; +} diff --git a/client/app/admin/admin.html b/client/app/admin/admin.html new file mode 100644 index 0000000..cbbe68a --- /dev/null +++ b/client/app/admin/admin.html @@ -0,0 +1,12 @@ +
+

The delete user and user index api routes are restricted to users with the 'admin' role.

+ +
diff --git a/client/app/admin/admin.routes.js b/client/app/admin/admin.routes.js new file mode 100644 index 0000000..5302702 --- /dev/null +++ b/client/app/admin/admin.routes.js @@ -0,0 +1,13 @@ +'use strict'; + +export default function routes($stateProvider) { + 'ngInject'; + + $stateProvider.state('admin', { + url: '/admin', + template: require('./admin.html'), + controller: 'AdminController', + controllerAs: 'admin', + authenticate: 'admin' + }); +} diff --git a/client/app/admin/index.js b/client/app/admin/index.js new file mode 100644 index 0000000..27a3638 --- /dev/null +++ b/client/app/admin/index.js @@ -0,0 +1,10 @@ +'use strict'; + +import angular from 'angular'; +import routes from './admin.routes'; +import AdminController from './admin.controller'; + +export default angular.module('app2App.admin', ['app2App.auth', 'ui.router']) + .config(routes) + .controller('AdminController', AdminController) + .name; diff --git a/client/app/app.config.js b/client/app/app.config.js new file mode 100644 index 0000000..218cf46 --- /dev/null +++ b/client/app/app.config.js @@ -0,0 +1,9 @@ +'use strict'; + +export function routeConfig($urlRouterProvider, $locationProvider) { + 'ngInject'; + + $urlRouterProvider.otherwise('/'); + + $locationProvider.html5Mode(true); +} diff --git a/client/app/app.constants.js b/client/app/app.constants.js new file mode 100644 index 0000000..61b0bd2 --- /dev/null +++ b/client/app/app.constants.js @@ -0,0 +1,7 @@ +'use strict'; + +import angular from 'angular'; + +export default angular.module('app2App.constants', []) + .constant('appConfig', require('../../server/config/environment/shared')) + .name; diff --git a/client/app/app.css b/client/app/app.css new file mode 100644 index 0000000..01c3373 --- /dev/null +++ b/client/app/app.css @@ -0,0 +1,125 @@ +@import '~bootstrap/dist/css/bootstrap.css'; +@import '~bootstrap-social/bootstrap-social.css'; +/** + * Bootstrap Fonts + */ + +@font-face { + font-family: 'Glyphicons Halflings'; + src: url('/assets/fonts/bootstrap/glyphicons-halflings-regular.eot'); + src: url('/assets/fonts/bootstrap/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), + url('/assets/fonts/bootstrap/glyphicons-halflings-regular.woff') format('woff'), + url('/assets/fonts/bootstrap/glyphicons-halflings-regular.ttf') format('truetype'), + url('/assets/fonts/bootstrap/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); +} + +@import '~font-awesome/css/font-awesome.css'; + +/** + *Font Awesome Fonts + */ + +@font-face { + font-family: 'FontAwesome'; + src: url('/assets/fonts/font-awesome/fontawesome-webfont.eot?v=4.1.0'); + src: url('/assets/fonts/font-awesome/fontawesome-webfont.eot?#iefix&v=4.1.0') format('embedded-opentype'), + url('/assets/fonts/font-awesome/fontawesome-webfont.woff?v=4.1.0') format('woff'), + url('/assets/fonts/font-awesome/fontawesome-webfont.ttf?v=4.1.0') format('truetype'), + url('/assets/fonts/font-awesome/fontawesome-webfont.svg?v=4.1.0#fontawesomeregular') format('svg'); + font-weight: normal; + font-style: normal; +} + +/** + * App-wide Styles + */ + +.browserupgrade { + margin: 0.2em 0; + background: #ccc; + color: #000; + padding: 0.2em 0; +} + + +/* Component styles are injected through gulp */ +/* inject:css */ +@import 'admin/admin.css'; +@import 'main/main.css'; +@import '../components/footer/footer.css'; +@import '../components/modal/modal.css'; +@import '../components/oauth-buttons/oauth-buttons.css'; +/* endinject */ + +.navbar { + margin-bottom: 0; + background-color: #2d363a; + border-radius: 0; + border-top: 0; + border-right: 0; + border-left: 0; +} + +.navbar-brand { + padding: 0; + padding-top: 15px; + margin-right: 15px; +} + +.navbar-default .navbar-nav > li > a:hover, .navbar-default .navbar-nav > li > a:focus { + color: whitesmoke; + background-color: transparent; +} + +.navbar-default .navbar-brand { + color: white; +} + +.navbar-default .navbar-nav > li > a { + color: white; +} + +.navbar-brand-header { + width: 100%; + margin-left: 100px; + font-size: 25px; +} + +.navbar-brand-header a:hover, a:focus { + color: #2d363a; + text-decoration: none; +} + +#header li a.nav-button:hover { + background-color: #333; +} + +#header li.dropdown.open a.dropdown-toggle { + background-color: #333; +} + +#header #logo { + position: absolute; + top: 0; + left: 0; + width: 91px; + height: 70px; + text-align: center; + background-color: #2c95dd; + z-index: 100; +} + +#header #logo img { + margin-top: 30px; +} + +#header a { + color: #fff; +} + +#header .dropdown li a { + color: #333; +} + +.ace_editor { height: 800px; } + diff --git a/client/app/app.js b/client/app/app.js new file mode 100644 index 0000000..c0c45e2 --- /dev/null +++ b/client/app/app.js @@ -0,0 +1,126 @@ +'use strict'; + +import angular from 'angular'; +// import ngAnimate from 'angular-animate'; +import ngCookies from 'angular-cookies'; +import ngResource from 'angular-resource'; +import ngSanitize from 'angular-sanitize'; + +import uiRouter from 'angular-ui-router'; +import uiBootstrap from 'angular-ui-bootstrap'; +import 'angular-validation-match'; + + +import 'angular-ui-ace'; + +import 'yamljs/dist/yaml.min'; + +import 'ansi-to-html'; + +import 'angular-markdown-directive'; + +import treecontrol from 'angular-tree-control'; + +import 'angular-tree-control/css/tree-control-attribute.css'; +import 'angular-tree-control/css/tree-control.css'; + +import { + routeConfig +} from './app.config'; + +import _Auth from '../components/auth/auth.module'; +import account from './account'; +import admin from './admin'; +import navbar from '../components/navbar/navbar.component'; +import footer from '../components/footer/footer.component'; +import main from './main/main.component'; +import DesignerComponent from './designer/designer.component'; +import ProjectComponent from './project/project.component'; +import InventoryComponent from './designer/inventory/inventory.component'; +import PlaybookComponent from './designer/playbook/playbook.component'; +import FileBrowserComponent from './designer/file_browser/file_browser.component'; +import RolesComponent from './designer/roles/roles.component'; +import RunsComponent from './runs/runs.component'; +import CustomModulesComponent from './custom_modules/custom_modules.component'; + +import Projects from './services/projects/projects.service'; +import ansible from './services/ansible/ansible.service'; +import YAML from './providers/yaml/yaml.service'; +import yamlFile from './services/yamlFile/yamlFile.service'; + +import customModules from './custom_modules/custom_modules.service'; + +import ansi2html from './providers/ansi2html/ansi2html.service'; + +import constants from './app.constants'; +import util from '../components/util/util.module'; + +import NewInventoryController from './designer/inventory/new_inventory/new_inventory.controller'; + + +import NewGroupController from './designer/inventory/new_group/new_group.controller'; +import NewHostController from './designer/inventory/new_host/new_host.controller'; +import ComplexVarController from './directives/complexVar/complexVar.controller'; +import NewPlaybookController from './designer/playbook/new_playbook/new_playbook.controller'; +import ExecutionController from './designer/execution/execution.controller'; +import NewPlayController from './designer/playbook/new_play/new_play.controller'; +import NewTaskController from './designer/tasks/new_task/new_task.controller'; + +import NewFileController from './designer/roles/new_file/new_file.controller'; +import NewRoleController from './designer/roles/new_role/new_role.controller'; +import SearchRoleController from './designer/roles/search_role/search_role.controller'; + +import ComplexVarModalController from './modals/complex_var_modal/complex_var_modal.controller'; + +import NewModuleController from './custom_modules/new_module/new_module.controller'; + +import dictToKeyValueArray from './filters/dictToKeyValueArray/dictToKeyValueArray.filter'; +import dictToKeyValueArraySimple from './filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter'; +import keyValueArrayToDict from './filters/keyValueArrayToDict/keyValueArrayToDict.filter'; +import keyValueArrayToArray from './filters/keyValueArrayToArray/keyValueArrayToArray.filter'; +import addDotInKey from './filters/addDotInKey/addDotInKey.filter'; +import removeDotInKey from './filters/removeDotInKey/removeDotInKey.filter'; +import json2yaml from './filters/json2yaml/json2yaml.filter'; + +import complexVar from './directives/complexVar/complexVar.directive'; +import tasks from './designer/tasks/tasks.directive'; + +import editor from './services/editor/editor.service'; + +import './app.css'; + +angular.module('app2App', [ngCookies, ngResource, ngSanitize, uiRouter, uiBootstrap, _Auth, account, + admin, 'validation.match', 'ui.ace', navbar, footer, main, constants, util, ansi2html, + // Components + DesignerComponent, ProjectComponent, InventoryComponent, PlaybookComponent, FileBrowserComponent, RolesComponent, RunsComponent, CustomModulesComponent, + // Services + YAML, yamlFile, Projects, ansible, ansi2html, editor, customModules, + // Controllers + NewInventoryController, NewGroupController, NewHostController, ComplexVarController, NewPlaybookController, ExecutionController, NewPlayController, NewTaskController, ComplexVarModalController, + NewFileController, NewRoleController, SearchRoleController, NewModuleController, + // Filters + dictToKeyValueArray, dictToKeyValueArraySimple, keyValueArrayToDict, keyValueArrayToArray, addDotInKey, removeDotInKey, json2yaml, + // Directives + complexVar, tasks, treecontrol, 'btford.markdown' + +]) + .config(routeConfig) + .run(function($rootScope, $location, Auth) { + 'ngInject'; + // Redirect to login if route requires auth and you're not logged in + + $rootScope.$on('$stateChangeStart', function(event, next) { + Auth.isLoggedIn(function(loggedIn) { + if(next.authenticate && !loggedIn) { + $location.path('/login'); + } + }); + }); + }); + +angular.element(document) + .ready(() => { + angular.bootstrap(document, ['app2App'], { + strictDi: true + }); + }); diff --git a/client/app/custom_modules/custom_modules.component.js b/client/app/custom_modules/custom_modules.component.js new file mode 100644 index 0000000..ad01ebd --- /dev/null +++ b/client/app/custom_modules/custom_modules.component.js @@ -0,0 +1,235 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './custom_modules.routes'; + +export class CustomModulesComponent { + /*@ngInject*/ + constructor($scope,customModules,$sce,ansi2html,Projects,$uibModal,YAML) { + 'ngInject'; + + $scope.custom_modules = []; + $scope.selectedModule = {module:{},module_code:"",module_unchanged_code:""}; + $scope.selected_module_code = "something"; + + $scope.showNewModuleForm = {value:false}; + + $scope.getProjects = function(){ + $scope.projects = Projects.resource.query(function(){ + if($scope.projects.length){ + $scope.selectedProjectID = localStorage.selectedProjectID || $scope.projects[0]._id; + $scope.projectSelected($scope.selectedProjectID) + } + + }) + }; + + $scope.projectSelected = function(projectID){ + + localStorage.selectedProjectID = projectID; + + $scope.selectedProject = Projects.resource.get({id: projectID},function(){ + Projects.selectedProject = $scope.selectedProject; + $scope.getCustomModules(); + }) + }; + + $scope.getProjects(); + + $scope.getCustomModules = function(){ + customModules.get(function(response){ + console.log(response.data); + var lines = response.data.split("\n"); + if(lines.length) + lines = lines + .filter(function(line){return line.indexOf(".py") > -1}) + .map(function(item){return {name:item}}); + $scope.custom_modules = lines; + + if($scope.selectedModule.module.name){ + $scope.selectedModule.module = $scope.custom_modules.filter(function(item){ + return (item.name == $scope.selectedModule.module.name) + })[0] + } + + + }); + }; + + $scope.loadingModuleCode = false + + $scope.showModuleCode = function(module_name){ + $scope.loadingModuleCode = true; + if(!module_name){ + $scope.selectedModule.module_code = "Select a module"; + return; + } + customModules.show(module_name,function(response) { + $scope.loadingModuleCode = false; + $scope.selectedModule.module_code = response.data.split("Stream :: close")[0]; + $scope.selectedModule.module_unchanged_code = angular.copy($scope.selectedModule.module_code); + }); + }; + + $scope.$watch('selectedModule.module',function(newValue,oldValue){ + if(newValue.name && newValue.name !== oldValue.name){ + $scope.selectedModule.module_code = "Loading Module Code..."; + $scope.showModuleCode(newValue.name) + } + }); + + $scope.code_has_changed = false; + + $scope.codeChanged = function(){ + console.log("Code Changed"); + if($scope.selectedModule.module_unchanged_code !== $scope.selectedModule.module_code){ + $scope.code_has_changed = true + }else{ + $scope.code_has_changed = false + } + }; + + $scope.discardCodeChanges = function(){ + $scope.selectedModule.module_code = angular.copy($scope.selectedModule.module_unchanged_code); + }; + + $scope.saveModule = function(){ + $scope.saving = true; + customModules.save($scope.selectedModule.module.name,$scope.selectedModule.module_code,function(response){ + $scope.saving = false; + $scope.code_has_changed = false; + $scope.selectedModule.module_unchanged_code = angular.copy($scope.selectedModule.module_code); + console.log("Success") + },function(response){ + $scope.saving = false; + console.error(response.data) + }) + }; + + $scope.testModule = function(){ + + var re = /([^]+DOCUMENTATION = '''\s+)([^]+?)(\s+'''[^]+)/; + var module_string = $scope.selectedModule.module_code.replace(re,'$2'); + + $scope.selectedModuleObject = YAML.parse(module_string); + + //var options_copy = angular.copy($scope.selectedModuleObject.options); + var options_copy = {}; + /*options_copy = options_copy.map(function(item){ + var temp_obj = {}; + temp_obj[item.name] = ""; + return temp_obj + });*/ + + var module_name = $scope.selectedModule.module.name; + var module_cached_args = null; + + try{ + module_cached_args = JSON.parse(localStorage['test_args_'+module_name]); + }catch (e){ + console.log("Error getting cached arguments."); + module_cached_args = null; + } + + angular.forEach($scope.selectedModuleObject.options,function(value,key){ + //var temp_obj = {}; + //temp_obj[key] = ""; + options_copy[key] = ""; + + if(module_cached_args && key in module_cached_args){ + options_copy[key] = module_cached_args[key]; + } + + }); + + + var variable = {name:'',complexValue:options_copy}; + $scope.showComplexVariable(variable); + + }; + + $scope.newModule = function(){ + $scope.showNewModuleForm.value = true; + $scope.$broadcast ('newModule'); + }; + + $scope.editModule = function(){ + $scope.showNewModuleForm.value = true; + $scope.$broadcast ('editModule'); + }; + + + + $scope.showComplexVariable = function(variable){ + $scope.result = ""; + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/modals/complex_var_modal/complexVariable.html', + controller: 'ComplexVarModalController', + size: 'sm', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + path: function () { + return variable.name + }, + hostvars: function(){ + return null + }, + members: function(){ + return variable.complexValue + } + } + }); + + modalInstance.result.then(function (module_args) { + + var module_name = $scope.selectedModule.module.name; + + /*var args = ""; + angular.forEach(selectedItem,function(value,key){ + + if(value){ + args += " " + key + "=" + value + } + + }); + + if(args){ + module_name += " -a " + args + }*/ + + localStorage['test_args_'+module_name] = JSON.stringify(module_args); + + $scope.testing = true; + + customModules.test(module_name,module_args,function(response) { + $scope.testing = false; + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data.split("Stream :: close")[0]).replace(/\n/g, "
")); + }, + function(response) { + $scope.testing = false; + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data.split("Stream :: close")[0]).replace(/\n/g, "
")); + }); + + }, function () { + + }); + + } + } +} + +export default angular.module('webAppApp.custom_modules', [uiRouter]) + .config(routes) + .component('customModules', { + template: require('./custom_modules.html'), + controller: CustomModulesComponent, + controllerAs: 'customModulesCtrl' + }) + .name; diff --git a/client/app/custom_modules/custom_modules.component.spec.js b/client/app/custom_modules/custom_modules.component.spec.js new file mode 100644 index 0000000..635b941 --- /dev/null +++ b/client/app/custom_modules/custom_modules.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: CustomModulesComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.custom_modules')); + + var CustomModulesComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + CustomModulesComponent = $componentController('custom_modules', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/custom_modules/custom_modules.html b/client/app/custom_modules/custom_modules.html new file mode 100644 index 0000000..7231f0c --- /dev/null +++ b/client/app/custom_modules/custom_modules.html @@ -0,0 +1,55 @@ +
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + +
SelectName
+ {{module.name}}
+
+ + + + +
+

+
+
+ +
+ +
+
+
+ +
+ +
+
+ diff --git a/client/app/custom_modules/custom_modules.routes.js b/client/app/custom_modules/custom_modules.routes.js new file mode 100644 index 0000000..3fe2542 --- /dev/null +++ b/client/app/custom_modules/custom_modules.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('custom_modules', { + url: '/custom_modules', + template: '' + }); +} diff --git a/client/app/custom_modules/custom_modules.service.js b/client/app/custom_modules/custom_modules.service.js new file mode 100644 index 0000000..55e0dee --- /dev/null +++ b/client/app/custom_modules/custom_modules.service.js @@ -0,0 +1,30 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function customModulesService($http,Projects) { + // AngularJS will instantiate a singleton by calling "new" on this function + + var uri = '/api/custom_modules'; + + this.get = function(successCallback,errorCallback){ + $http.post(uri + '/query',{ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.show = function(customModule,successCallback,errorCallback){ + $http.post(uri+ '/' + customModule+'/get',{ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.test = function(customModule,module_args,successCallback,errorCallback){ + $http.post(uri + '/' + customModule + '/test',{ansibleEngine:Projects.selectedProject.ansibleEngine,moduleArgs:module_args}).then(successCallback,errorCallback) + }; + + this.save = function(customModule,customModuleCode,successCallback,errorCallback){ + $http.post(uri + '/' + customModule,{ansibleEngine:Projects.selectedProject.ansibleEngine,custom_module_code:customModuleCode}).then(successCallback,errorCallback) + } + +} + +export default angular.module('webAppApp.custom_modules_service', []) + .service('customModules', customModulesService) + .name; diff --git a/client/app/custom_modules/custom_modules.service.spec.js b/client/app/custom_modules/custom_modules.service.spec.js new file mode 100644 index 0000000..c6c3204 --- /dev/null +++ b/client/app/custom_modules/custom_modules.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: customModules', function() { + // load the service's module + beforeEach(module('webAppApp.custom_modules')); + + // instantiate service + var customModules; + beforeEach(inject(function(_customModules_) { + customModules = _customModules_; + })); + + it('should do something', function() { + expect(!!customModules).to.be.true; + }); +}); diff --git a/client/app/custom_modules/new_module/new_module.controller.js b/client/app/custom_modules/new_module/new_module.controller.js new file mode 100644 index 0000000..c896d17 --- /dev/null +++ b/client/app/custom_modules/new_module/new_module.controller.js @@ -0,0 +1,280 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newModuleController($scope,$filter,customModules,ansible,YAML) { + + $scope.optionTypes = ['str','list','dict','bool','int','float','path','raw','jsonarg','json','bytes','bits']; + + var defaultModule = { + module:null, + short_description:"", + description:"", + version_added:"", + author:"", + notes: "", + requirements: "", + options:[ + { + name:"parameter1", + description: 'Description of parameter 1', + required: true, + default: null, + choices: '"choice1", "choice2"', + aliases: '"option1", "argument1"', + type: "" + } + ] + }; + + + $scope.loadDefaultTemplate = function(){ + $scope.newModule = angular.copy(defaultModule); + $scope.selectedModule.module_code = "Loading Template.."; + + customModules.show('template.py',function(response) { + $scope.selectedModule.module_code = response.data.split("Stream :: close")[0]; + }); + }; + + $scope.$watch('newModule',function(newValue,oldValue){ + + updateDocumentation(newValue); + updateParameters(newValue); + updateExamples(newValue); + + },true); + + var updateParameters = function(newValue){ + newValue = angular.copy(newValue); + + var parameters_definition_lines = []; + var parameters_retreive_lines = []; + angular.forEach(newValue.options,function(option){ + if(option.name) { + var line = option.name + "=dict("; + + var line_arguments = []; + if (option.required)line_arguments.push("required=True"); + if (!option.required && option.default)line_arguments.push("default='" + option.default + "'"); + if (option.type)line_arguments.push("type='" + option.type + "'"); + if (option.choices)line_arguments.push("choices=[" + option.choices + "]"); + if (option.aliases)line_arguments.push("aliases=[" + option.aliases + "]"); + + line += line_arguments.join(","); + line += ")"; + + parameters_definition_lines.push(line); + parameters_retreive_lines.push(option.name + ' = module.params[\'' + option.name + '\']') + } + }); + + var parameters_definition_string = parameters_definition_lines.join(",\n "); + var parameters_retreive_string = parameters_retreive_lines.join("\n "); + + var re = /(# <--Begin Parameter Definition -->\s+ )([^]+)(\s+ # <--END Parameter Definition -->)/; + $scope.selectedModule.module_code = $scope.selectedModule.module_code.replace(re,"$1" + parameters_definition_string + "$3"); + + var supports_check_mode_string = '\n'; + if(newValue.supports_check_mode){ + supports_check_mode_string = '\n supports_check_mode=True\n' + } + + var re2 = /(# <--Begin Supports Check Mode -->)([^]+)( # <--End Supports Check Mode -->)/; + $scope.selectedModule.module_code = $scope.selectedModule.module_code.replace(re2,"$1" + supports_check_mode_string + "$3"); + + var re3 = /(# <--Begin Retreiving Parameters -->\s+ )([^]+)(\s+ # <--End Retreiving Parameters -->)/; + $scope.selectedModule.module_code = $scope.selectedModule.module_code.replace(re3,"$1" + parameters_retreive_string + "$3"); + + }; + + var updateDocumentation = function(newValue){ + newValue = angular.copy(newValue); + newValue.options = convertOptionsToObject(newValue.options); + + delete newValue['supports_check_mode']; + + if(newValue.description) + newValue.description = newValue.description.split(";"); + + if(newValue.notes) + newValue.notes = newValue.notes.split(";"); + + if(newValue.requirements) + newValue.requirements = newValue.requirements.split(";"); + + $scope.documentation_yaml = '---\n' + $filter('json2yaml')(angular.toJson(newValue)).toString().replace(/__dot__/g,"."); + + //var re = /(.*DOCUMENTATION = '''\n)([^]+?)(\n'''.*)/; + var re = /([^]+DOCUMENTATION = '''\s+)([^]+?)(\s+'''[^]+)/; + $scope.selectedModule.module_code = $scope.selectedModule.module_code.replace(re,'$1' + $scope.documentation_yaml + '$3'); + }; + + var updateExamples = function(newValue){ + newValue = angular.copy(newValue); + + var moduleCopy = { + + }; + + moduleCopy[newValue.module] = convertOptionsToExampleObject(newValue.options); + + $scope.example_yaml = YAML.stringify(moduleCopy,4); + + //var re = /(.*DOCUMENTATION = '''\n)([^]+?)(\n'''.*)/; + var re = /([^]+EXAMPLES = '''[^]+# <-- -->\s+)([^]+?)(\s+# <-- \/ -->\s+'''[^]+)/; + $scope.selectedModule.module_code = $scope.selectedModule.module_code.replace(re,'$1' + $scope.example_yaml + '$3'); + }; + + var convertOptionsToObject = function(options){ + + var result = {}; + + angular.forEach(options,function(option){ + if(option.name){ + result[option.name] = { + description: option.description + }; + + if(option.required) + result[option.name]['required'] = "True"; + else + delete result[option.name]['required']; + + if(!option.required && option.default) + result[option.name]['default'] = option.default; + + if(option.choices){ + result[option.name]['choices'] = "[" + option.choices + "]" + } + + if(option.aliases){ + result[option.name]['aliases'] = "[" + option.aliases + "]" + } + } + + }); + + return result + + }; + + var convertOptionsToExampleObject = function(options){ + + var result = {}; + + angular.forEach(options,function(option){ + if(option.name){ + result[option.name] = "value"; + } + }); + + return result + + }; + + var convertOptionsToArrays = function(options){ + + var result = []; + + angular.forEach(options,function(value,key){ + var option = { + name: key, + description: value.description, + required: value.required, + default:value.default + }; + + if(value.choices && value.choices.length) + option['choices'] = value.choices.map(function(item){return ('"' + item + '"')}).join(",") + + if(value.aliases && value.aliases.length) + option['aliases'] = value.aliases.map(function(item){return ('"' + item + '"')}).join(",") + + result.push(option) + }); + + return result + + }; + + $scope.saveNewModule = function(){ + $scope.saving = true; + customModules.save($scope.newModule.module + '.py',$scope.selectedModule.module_code,function(response){ + $scope.saving = false; + $scope.getCustomModules(); + + ansible.getAnsibleModules(function(response){ + + }, function(response){ + + },null,true); + $scope.cancelNewModule(); + },function(response){ + $scope.saving = false; + console.error(response.data) + }) + }; + + $scope.cancelNewModule = function(){ + $scope.showNewModuleForm.value = false; + $scope.$parent.showModuleCode($scope.selectedModule.module.name) + }; + + var getPropertiesFromCode = function(module_code){ + + //var re = /([^]+DOCUMENTATION = '''\n)([^]+?)(\n'''[^]+)/; + var re = /([^]+DOCUMENTATION = '''\s+)([^]+?)(\s+'''[^]+)/; + var module_string = $scope.selectedModule.module_code.replace(re,'$2'); + + $scope.newModule = YAML.parse(module_string); + $scope.newModule.options = convertOptionsToArrays($scope.newModule.options); + + if($scope.newModule.description && $scope.newModule.description.length) + $scope.newModule.description = $scope.newModule.description.join(";"); + + if($scope.newModule.notes && $scope.newModule.notes.length) + $scope.newModule.notes = $scope.newModule.notes.join(";"); + + if($scope.newModule.requirements && $scope.newModule.requirements.length) + $scope.newModule.requirements = $scope.newModule.requirements.join(";"); + + + re = /([^]+# <--Begin Parameter Definition -->\s+ )([^]+)(\s+ # <--END Parameter Definition -->[^]+)/; + var parameter_string = $scope.selectedModule.module_code.replace(re,"$2"); + + // Read property type form parameter definition + re = /\s+(.*?)=.*type=(.*?)[,\)].*/g; + var m; + + while ((m = re.exec(parameter_string)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + if(m[1]){ + angular.forEach($scope.newModule.options,function(option){ + if(option.name === m[1]){ + option.type = m[2].replace(/'/g,'') + } + }) + } + + } + + + }; + + $scope.$on('editModule', function(e) { + getPropertiesFromCode($scope.selected_module_code) + }); + + $scope.$on('newModule', function(e) { + $scope.loadDefaultTemplate(); + }); +} + +export default angular.module('webAppApp.new_module', []) + .controller('NewModuleController', newModuleController) + .name; diff --git a/client/app/custom_modules/new_module/new_module.controller.spec.js b/client/app/custom_modules/new_module/new_module.controller.spec.js new file mode 100644 index 0000000..b5219bf --- /dev/null +++ b/client/app/custom_modules/new_module/new_module.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewModuleCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_module')); + + var NewModuleCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewModuleCtrl = $controller('NewModuleCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/custom_modules/new_module/new_module.html b/client/app/custom_modules/new_module/new_module.html new file mode 100644 index 0000000..aa0dcf6 --- /dev/null +++ b/client/app/custom_modules/new_module/new_module.html @@ -0,0 +1,144 @@ +
+ +
+
+
New Module
+
+
+
+

+ + +

+
+
+

+ + +

+
+
+

+ + +

+
+
+

+ + +

+
+
+ +
+
+

+ + +

+
+
+

+ + +

+
+
+ + +

+ + +

+ +

+ + +

+ +
+
+
Options
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+ +
+
+
+ + +
+
+ + + + + +
+ + +
diff --git a/client/app/designer/designer.component.js b/client/app/designer/designer.component.js new file mode 100644 index 0000000..0c2a381 --- /dev/null +++ b/client/app/designer/designer.component.js @@ -0,0 +1,89 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './designer.routes'; + +export class DesignerComponent { + /*@ngInject*/ + constructor($scope,Projects,ansible) { + 'ngInject'; + $scope.selectedInventoryFileName = null; + + /** + * Get list of projects from server + */ + $scope.getProjects = function(){ + $scope.projects = Projects.resource.query(function(){ + if($scope.projects.length){ + $scope.selectedProjectID = localStorage.selectedProjectID || $scope.projects[0]._id; + $scope.projectSelected($scope.selectedProjectID) + } + + }) + }; + + /** + * On ProjectSelected - set selectedProjectID in cache + * @param projectID + */ + $scope.projectSelected = function(projectID){ + localStorage.selectedProjectID = projectID; + + $scope.selectedProject = Projects.resource.get({id: projectID},function(){ + Projects.selectedProject = $scope.selectedProject; + $scope.listOfInventoryFiles(); + $scope.$broadcast('projectLoaded'); + }) + + }; + + + /** + * Get List of inventory files in project root folder + */ + $scope.listOfInventoryFiles = function(){ + + var rolesTestFolder = null; + + /*if(roleName){ + rolesTestFolder = projectFolder + '/' + roleName + '/tests' + }*/ + + ansible.getInventoryList(function(response){ + $scope.inventoryFiles = response.data; + console.log($scope.inventoryFiles); + Projects.selectedInventoryFileName = localStorage.selectedInventoryFileName || $scope.inventoryFiles[0]; + localStorage.selectedInventoryFileName = $scope.inventoryFiles[0]; + $scope.selectedInventoryFileName = localStorage.selectedInventoryFileName + }, + function(response){ + $scope.err_msg = response.data + },rolesTestFolder) + }; + + /** + * Set selected inventory file in local cache. + * @param selectedInventoryFileName - Selected inventory file name + */ + $scope.inventoryFileSelected = function(selectedInventoryFileName){ + localStorage.selectedInventoryFileName = selectedInventoryFileName; + }; + + /** + * Main - Get Projects + */ + + $scope.getProjects(); + } +} + +export default angular.module('webAppApp.designer', [uiRouter]) + .config(routes) + .component('designer', { + template: require('./designer.html'), + controller: DesignerComponent, + controllerAs: 'designerCtrl' + }) + .name; diff --git a/client/app/designer/designer.component.spec.js b/client/app/designer/designer.component.spec.js new file mode 100644 index 0000000..ad0bc81 --- /dev/null +++ b/client/app/designer/designer.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: DesignerComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.designer')); + + var DesignerComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + DesignerComponent = $componentController('designer', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/designer.css b/client/app/designer/designer.css new file mode 100644 index 0000000..791fbcc --- /dev/null +++ b/client/app/designer/designer.css @@ -0,0 +1,10 @@ +.logconsole { + font:12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas'; + color: #fff; + padding:5px; + margin:5px; + font-size:14px; + background: black; +} + +.ace_editor { height: 800px; } diff --git a/client/app/designer/designer.html b/client/app/designer/designer.html new file mode 100644 index 0000000..70def7a --- /dev/null +++ b/client/app/designer/designer.html @@ -0,0 +1,55 @@ +
+
+
+
+
+
Projects
+
+ +
+ Ansible Version:{{selectedProject.ansibleVersion}} +
+
+
+
+ +
+
+
Inventory Files
+
+ +
+ An inventory file to work with +
+
+
+
+ +
+
+
Menu
+
+ +
+ + + + +
+ +
+
+
+
{{err_msg}}
+
+ +
+ +
+ +
+ +
+
diff --git a/client/app/designer/designer.routes.js b/client/app/designer/designer.routes.js new file mode 100644 index 0000000..c559162 --- /dev/null +++ b/client/app/designer/designer.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('designer', { + url: '/designer', + template: '' + }); +} diff --git a/client/app/designer/execution/executeModal.html b/client/app/designer/execution/executeModal.html new file mode 100644 index 0000000..4bd5d1f --- /dev/null +++ b/client/app/designer/execution/executeModal.html @@ -0,0 +1,185 @@ + + + + diff --git a/client/app/designer/execution/execution.controller.js b/client/app/designer/execution/execution.controller.js new file mode 100644 index 0000000..15f270d --- /dev/null +++ b/client/app/designer/execution/execution.controller.js @@ -0,0 +1,475 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function executionController($scope,$sce, $uibModalInstance, $timeout, ansi2html, ansible, tags, selectedProject, selectedPlaybook, selectedPlay, executionType, executionName, readOnly, runData, projectFolder, roleName) { + 'ngInject'; + $scope.view = 'console'; + $scope.selectedInventoryFile = {value:null}; + $scope.verbose_detail = {value:null}; + $scope.verbose = {value:'verbose'}; + $scope.check_mode = { + value: 'No_Check' + }; + $scope.additional_tags = {show: false}; + $scope.additional_vars = {show: false}; + $scope.refreshLog = true; + $scope.all_tags = []; + $scope.all_hosts = []; + $scope.readOnly = readOnly; + $scope.readyForPlay = false; + + /** + * Execute Ansible Playbook + */ + $scope.executeAnsiblePlayBook = function(){ + $scope.AnsiblePlayBookLoading = true; + var reqBody = {}; + //reqBody.inventory_file_contents = inventory_file_contents; + //reqBody.playbook_file_contents = yaml; + //reqBody.tags = tags || []; + reqBody.tags = []; + + $scope.all_tags.map(tag => { + if(tag.selected){ + if(tag.name) + reqBody.tags.push(tag.name.trim()) + } + }); + + reqBody.limit_to_hosts = []; + + $scope.all_hosts.map(host => { + if(host.selected){ + if(host.name) + reqBody.limit_to_hosts.push(host.name.trim()) + } + }); + + reqBody.verbose = $scope.verbose_detail.value || $scope.verbose.value; + reqBody.check_mode = $scope.check_mode.value; + + reqBody.inventory_file_name = $scope.selectedInventoryFile.value; + + if(roleName){ + reqBody.inventory_file_name = roleName + '/tests/' + reqBody.inventory_file_name; + } + + + console.log("Check Mode = " + reqBody.check_mode); + + reqBody.selectedPlaybook = selectedPlaybook.playbook; + reqBody.executionType = executionType; + reqBody.executionName = executionName; + + reqBody.ansibleEngine = angular.copy(selectedProject.ansibleEngine); + + // Override project folder for roles + if(projectFolder) + reqBody.ansibleEngine.projectFolder = projectFolder; + + if(selectedPlay && selectedPlay.play) + reqBody.host = selectedPlay.play.hosts; + + $scope.result = "Running..."; + + ansible.executeAnsiblePlayBook(reqBody,function(response){ + //$scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
")); + $scope.refreshLog = true; + $scope.executionData = response.data; + + setTimeout(function(){ + $scope.refreshLogs(); + },3000); + + }, function(response){ + if(response.data) + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
")); + $scope.AnsiblePlayBookLoading = false; + console.log("error" + $scope.result) + }, 'PlaybookExecutionModal'); + + }; + + /*setTimeout(function(){ + $scope.executeAnsiblePlayBook(); + },200);*/ + + + /** + * Get logs + */ + $scope.getLogs = function(){ + ansible.getLogs($scope.executionData,function(successResponse) { + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(successResponse.data.replace('SCRIPT_FINISHED','')).replace(/\n/g, "
")); + + if(successResponse.data.indexOf('SCRIPT_FINISHED') > -1){ + $scope.refreshLog = false; + $scope.AnsiblePlayBookLoading = false; + } + $scope.processAnsibleOutput(successResponse.data) + + }); + }; + + /** + * Refersh Logs + */ + $scope.refreshLogs = function(){ + if($scope.logRefreshTimer){ + $timeout.cancel( $scope.logRefreshTimer ); + } + + $scope.getLogs(); + $scope.logRefreshTimer = $timeout( + function(){ + //$scope.getLogs(tile); + if($scope.refreshLog) { + $scope.refreshLogs(); + } + }, + 10000 + ); + + $scope.$on( + "$destroy", + function( event ) { + $timeout.cancel( $scope.logRefreshTimer ); + } + ); + + }; + + /** + * Close the modal + */ + $scope.ok = function () { + $uibModalInstance.close(null); + }; + + /** + * Cancel modal + */ + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; + + /** + * Run a random command on the server + * TODO: Remove this later. + * @param command + */ + $scope.runCommand = function(command){ + command = command || $scope.command; + ansible.executeCommand( command, + function(response){ + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
").replace(/ /g," ")); + + }, function(response){ + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
")); + + }) + }; + + /** + * Run Ansible Playbook + * @constructor + */ + $scope.Run = function(){ + $scope.executeAnsiblePlayBook(); + }; + + /** + * This is used when viewing the logs from the Runs view + */ + if($scope.readOnly){ + $scope.executionData = runData; + $scope.refreshLog = true; + $scope.refreshLogs() + } + + /** + * Get List of inventory files + */ + $scope.listOfInventoryFiles = function(){ + + var rolesTestFolder = null; + + if(roleName){ + rolesTestFolder = projectFolder + '/' + roleName + '/tests' + } + + ansible.getInventoryList(function(response){ + $scope.inventoryFiles = response.data; + if($scope.inventoryFiles.length) + $scope.selectedInventoryFile = {value:$scope.inventoryFiles[0]}; + /** + * Run Get Tags + */ + if(!readOnly) + $scope.getTags(); + }, + function(response){ + /*$scope.err_msg = response.data;*/ + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
")); + $scope.view = 'console' + },rolesTestFolder) + }; + + $scope.listOfInventoryFiles(); + + /** + * Get List of Tags based on playbook and inventory file + */ + $scope.getTags = function(){ + var inventory_file_name = $scope.selectedInventoryFile.value; + + if(roleName){ + inventory_file_name = roleName + '/tests/' + inventory_file_name; + } + + var selectedPlaybookName = selectedPlaybook.playbook; + + var ansibleEngine = angular.copy(selectedProject.ansibleEngine); + + // Override project folder for roles + if(projectFolder) + ansibleEngine.projectFolder = projectFolder; + + ansible.getTagList(selectedPlaybookName,inventory_file_name,ansibleEngine, + function(response){ + console.log(response.data) + + /*var re = /TAGS: \[(.*)\]/g; + var m; + + var all_tags = [] + + while ((m = re.exec(response.data)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + if(m[1]) + all_tags.push(m[1]) + } + + $scope.all_tags = all_tags.join(',').split(',');*/ + + if(!response.data.playbooks)return null; + + var playbooks = response.data.playbooks; + $scope.all_hosts = []; + $scope.all_tags = []; + + angular.forEach(playbooks, playbook => { + angular.forEach(playbook.plays, play => { + $scope.all_hosts = $scope.all_hosts.concat(play.hosts); + $scope.all_tags = $scope.all_tags.concat(play.tags); + angular.forEach(play.tasks, task => { + $scope.all_tags = $scope.all_tags.concat(task.tags); + }) + }) + }); + + // Get Unique List of tags + $scope.all_tags = Array.from(new Set($scope.all_tags)); + + // Get Unique List of hosts + $scope.all_hosts = Array.from(new Set($scope.all_hosts)); + + $scope.all_hosts = $scope.all_hosts.map(host => {return {name:host,selected:false}}); + + $scope.all_tags = $scope.all_tags.map(tag => {return {name:tag,selected:false}}); + + if(tags){ + angular.forEach(tags, tag => { + var tag_found = false; + angular.forEach($scope.all_tags, (all_tag,index) => { + if(tag == all_tag.name){ + tag_found = true; + all_tag.selected = true + } + }); + if(!tag_found) + $scope.all_tags.push({name:tag,selected:true}) + }) + + } + + $scope.readyForPlay = true; + + }, + function(error){ + //console.log(error.data) + //$scope.err_msg = error.data; + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(error.data).replace(/\n/g, "
")); + $scope.view = 'console' + }) + + }; + + /** + * Process Ansible Output and show graphically + * @param ansibleOutput + */ + $scope.processAnsibleOutput_old = function(ansibleOutput){ + + $scope.ansibleOutputResult = []; + //https://regex101.com/r/yD6lZ6/1 + //var re = /(PLAY|TASK) \[(.*)\] (.*)\n(.*?):([^]*?)(?=TASK|PLAY)/gm; + //var re = /(PLAY|TASK) \[(.*)\] (.*)\n(?:(.*?)\s?(.*): \[(.*)\](.*)=> ([^]*?)(?=TASK|PLAY)|(.*?): \[(.*)\](.*)|(.*)|(?=TASK|PLAY))/gm + //var re = /(PLAY|TASK) \[(.*)\] (.*)\n(?:(.*?)\s?(.*): \[(.*)\](.*)=> ([^]*?)(?=\n\n)|(.*?): \[(.*)\](.*)|(.*)|(?=\n\n))/gm; + var re = /({[^]+})/g + var m; + + while ((m = re.exec(ansibleOutput)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + + var type = m[1]; //TASK,PLAY + var name = m[2]; // ansible-role-vra : debug delete instance + var status = m[5]; //ok, skipping, failed + var host = m[6]; //localhost , localhost -> localhost + var status_2 = m[7]; + var result = m[8]; + + if(result){ + //var result_object_string = result.replace(/({[^]+})[^]+/,"$1"); + var result_object_string = result; + var resultObject = null; + try{ + resultObject = JSON.parse(result_object_string); + + //resultObject.formattedStdErr = resultObject.stderr.replace(/\\r\\n/g,'
').replace(/\\n/g, "
"); + resultObject.formattedStdErr = resultObject.stderr.replace(/\n/g,'
'); + resultObject.formattedStdOut = resultObject.stderr.replace(/\n/g,'
'); + + }catch(e){ + console.log("Error converting ansible output result object to javascript") + } + } + + $scope.ansibleOutputResult.push({ + type:type, + name:name, + status:status, + host:host, + status_2:status_2, + resultString:result, + resultObject:resultObject + }) + + } + + } + + + $scope.processAnsibleOutput = function(ansibleOutput){ + + $scope.ansibleOutputResult = []; + $scope.ansibleOutputObject = { + 'plays' : [], + 'stats' : {} + + } + //var re = /(.*{[^]+}.*)/g; + var re = /--------BEGIN--------([^]+?)--------END--------/gm; + var m; + + while ((m = re.exec(ansibleOutput)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + + try{ + //$scope.ansibleOutputObject = JSON.parse(m[1]); + var resultItem = JSON.parse(m[1]); + if('play' in resultItem){ + $scope.ansibleOutputObject.plays.push(resultItem); + } else if('task' in resultItem){ + + var current_play = $scope.ansibleOutputObject.plays[$scope.ansibleOutputObject.plays.length-1] + var newTask = true; + angular.forEach(current_play.tasks, (task, index)=>{ + if(task.task.id === resultItem.task.id){ + newTask = false; + current_play.tasks[index] = resultItem + } + }) + + + if(newTask) + current_play.tasks.push(resultItem); + + } else if('stats' in resultItem){ + $scope.ansibleOutputObject.stats = resultItem.stats; + } + + }catch(e){ + console.log("Error parsing ansible output" + e); + } + + + //var plays = $scope.ansibleOutputObject.plays; + + } + + console.log($scope.ansibleOutputObject); + + /*while ((m = re.exec(ansibleOutput)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + + + + var type = m[1]; //TASK,PLAY + var name = m[2]; // ansible-role-vra : debug delete instance + var status = m[5]; //ok, skipping, failed + var host = m[6]; //localhost , localhost -> localhost + var status_2 = m[7]; + var result = m[8]; + + if(result){ + //var result_object_string = result.replace(/({[^]+})[^]+/,"$1"); + var result_object_string = result; + var resultObject = null; + try{ + resultObject = JSON.parse(result_object_string); + + //resultObject.formattedStdErr = resultObject.stderr.replace(/\\r\\n/g,'
').replace(/\\n/g, "
"); + resultObject.formattedStdErr = resultObject.stderr.replace(/\n/g,'
'); + resultObject.formattedStdOut = resultObject.stderr.replace(/\n/g,'
'); + + }catch(e){ + console.log("Error converting ansible output result object to javascript") + } + } + + $scope.ansibleOutputResult.push({ + type:type, + name:name, + status:status, + host:host, + status_2:status_2, + resultString:result, + resultObject:resultObject + }) + + }*/ + + } +} + +export default angular.module('webAppApp.execution', []) + .controller('ExecutionController', executionController) + .name; diff --git a/client/app/designer/execution/execution.controller.spec.js b/client/app/designer/execution/execution.controller.spec.js new file mode 100644 index 0000000..5731261 --- /dev/null +++ b/client/app/designer/execution/execution.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: ExecutionCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.execution')); + + var ExecutionCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + ExecutionCtrl = $controller('ExecutionCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/file_browser/file_browser.component.js b/client/app/designer/file_browser/file_browser.component.js new file mode 100644 index 0000000..45104ba --- /dev/null +++ b/client/app/designer/file_browser/file_browser.component.js @@ -0,0 +1,104 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './file_browser.routes'; + +export class FileBrowserComponent { + /*@ngInject*/ + constructor($scope,ansible,editor) { + 'ngInject'; + $scope.treeOptions = { + nodeChildren: "children", + dirSelectable: true, + isLeaf: function (node) { + return !(node.type === 'directory'); + }, + injectClasses: { + ul: "a1", + li: "a2", + liSelected: "a7", + iExpanded: "a3", + iCollapsed: "a4", + iLeaf: "a5", + label: "a6", + labelSelected: "a8" + } + }; + + $scope.editContent = false; + $scope.selectedFile = {showSource: true}; + + var loadProjectFiles = function(){ + ansible.getProjectFiles(function(response){ + $scope.projectFiles = response.data; + },function(error){ + + }); + }; + + $scope.$on('projectLoaded',function(){ + loadProjectFiles(); + }); + + if($scope.$parent.selectedProject && $scope.$parent.selectedProject.ansibleEngine) { + loadProjectFiles(); + } + + /** + * Show selected item in the tree + * @param file + * @param parent + */ + $scope.showSelected = function (file, parent) { + + if (file.children) { + $scope.selectedFile.content = JSON.stringify(file, null, '\t'); + $scope.docType = 'json'; + $scope.selectedFile.tasks = null; + return; + } + + var command = 'cat "' + file.path + '"'; + $scope.showSource = true; + $scope.markdownContent = ''; + $scope.docType = 'text'; + $scope.selectedFile.content = 'Loading..'; + $scope.selectedFile.tasks = null; + $scope.selectedFileName = file.name; + $scope.selectedFilePath = file.path; + $scope.parentNode = parent; + + ansible.executeCommand(command, + function (response) { + console.log(response.data) + editor.setContentAndType(response.data, file, $scope.selectedFile); + + var parentDirectory = file.path.replace(/^(.+)\/(.+)\/([^/]+)$/, "$2"); + if (parentDirectory == 'tasks') { + $scope.selectedFile.tasks = YAML.parse(response.data) || []; + } + + if (parentDirectory == 'group_vars' || parentDirectory == 'host_vars') { + $scope.selectedFile.docType = 'yaml'; + } + + //$scope.selectedFile.content = response.data; + + }, function (response) { + $scope.selectedFile.content = response.data; + + }) + }; + } +} + +export default angular.module('webAppApp.file_browser', [uiRouter]) + .config(routes) + .component('fileBrowser', { + template: require('./file_browser.html'), + controller: FileBrowserComponent, + controllerAs: 'fileBrowserCtrl' + }) + .name; diff --git a/client/app/designer/file_browser/file_browser.component.spec.js b/client/app/designer/file_browser/file_browser.component.spec.js new file mode 100644 index 0000000..7c9b282 --- /dev/null +++ b/client/app/designer/file_browser/file_browser.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: FileBrowserComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.file_browser')); + + var FileBrowserComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + FileBrowserComponent = $componentController('file_browser', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/file_browser/file_browser.html b/client/app/designer/file_browser/file_browser.html new file mode 100644 index 0000000..8fe2dec --- /dev/null +++ b/client/app/designer/file_browser/file_browser.html @@ -0,0 +1,33 @@ + +
+

File Browser

+
+ + {{node.name}} + +
+ +
+ + + + +
+ +
+ +
+
+
+ +
diff --git a/client/app/designer/file_browser/file_browser.routes.js b/client/app/designer/file_browser/file_browser.routes.js new file mode 100644 index 0000000..37d510e --- /dev/null +++ b/client/app/designer/file_browser/file_browser.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('designer.file_browser', { + url: '/file_browser', + template: '' + }); +} diff --git a/client/app/designer/inventory/inventory.component.js b/client/app/designer/inventory/inventory.component.js new file mode 100644 index 0000000..1334ed9 --- /dev/null +++ b/client/app/designer/inventory/inventory.component.js @@ -0,0 +1,278 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './inventory.routes'; + + +export class InventoryComponent { + /*@ngInject*/ + constructor($scope, $uibModal, ansible) { + + 'ngInject'; + $scope.selectedInventory = {inventory: "", content: ""}; + + $scope.editInventory = {value: false}; + $scope.selectedGroup = {group: null}; + $scope.selectedHost = {host: null}; + + $scope.complexVar = {}; + + $scope.$on('projectLoaded', function () { + $scope.getInventorys() + }); + + //To fix a warning message in console + $scope.aceLoaded = function(_editor){ + _editor.$blockScrolling = Infinity; + }; + + // --------------------------------------- PLAYBOOKS ---------------- + + $scope.getInventorys = function () { + ansible.getInventoryList( + function (response) { + $scope.inventorys = response.data; + }, + function (response) { + console.log(response.data) + } + ) + }; + + + if ($scope.$parent.selectedProject && $scope.$parent.selectedProject.ansibleEngine) { + $scope.getInventorys() + } + + $scope.loadingModuleCode = false; + + $scope.showInventoryCode = function (inventory_name) { + $scope.loadingModuleCode = true; + if (!inventory_name) { + $scope.selectedInventory.content = "Select a module"; + return; + } + ansible.readInventory(inventory_name, function (response) { + $scope.loadingModuleCode = false; + $scope.selectedInventory.content = response.data.split("Stream :: close")[0]; + + $scope.inventory_data_json = ansible.parseINIString($scope.selectedInventory.content); + $scope.inventory_data_json['name'] = inventory_name + + }); + }; + + $scope.$watch('selectedInventory.inventory', function (newValue, oldValue) { + if (newValue && newValue !== oldValue) { + $scope.selectedInventory.content = "Loading Code..."; + $scope.showInventoryCode(newValue) + } + }); + + + $scope.showCreatInventoryModal = function () { + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/inventory/new_inventory/new_inventory.html', + controller: 'NewInventoryController', + size: 'md', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + selectedProject: function () { + return $scope.$parent.selectedProject + } + } + }); + + modalInstance.result.then(function () { + $scope.getInventorys(); + }, function () { + + }); + + }; + + $scope.editGroup = function (group) { + $scope.showCreateGroupModal(group); + }; + + $scope.showCreateGroupModal = function (editGroup) { + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/inventory/new_group/new_group.html', + controller: 'NewGroupController', + size: 'lg', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + selectedProject: function () { + return $scope.$parent.selectedProject + }, + + editGroup: function () { + return editGroup + } + } + }); + + modalInstance.result.then(function (group) { + if(!editGroup)$scope.addGroup(group); + else $scope.selectedInventory.content = ansible.jsonToAnsibleInventoryIni($scope.inventory_data_json); + + $scope.saveInventory(); + }, function () { + + }); + + }; + + $scope.editHost = function (host) { + + var hostMemberOfGroups = getHostMemberOfGroups(host); + + $scope.showCreateHostModal({name: host, members: hostMemberOfGroups.join(',')}); + }; + + $scope.showCreateHostModal = function (editHost) { + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/inventory/new_host/new_host.html', + controller: 'NewHostController', + size: 'lg', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + selectedProject: function () { + return $scope.$parent.selectedProject + }, + editHost: function () { + return editHost + } + } + }); + + modalInstance.result.then(function (host) { + $scope.addHost(host); + $scope.saveInventory(); + }, function () { + + }); + }; + + $scope.saveInventory = function () { + $scope.saveInventoryLoading = true; + ansible.createInventory($scope.selectedInventory.inventory, $scope.selectedInventory.content, + function (response) { + $scope.saveInventoryLoading = false; + $scope.editInventory.value = false; + }, + function (response) { + $scope.saveInventoryLoading = false; + $scope.err_msg = response.data; + }) + }; + + $scope.deleteInventory = function () { + $scope.deleteInventoryLoading = true; + ansible.deleteInventory($scope.selectedInventory.inventory, + function (response) { + $scope.deleteInventoryLoading = false; + $scope.selectedInventory.inventory = ""; + $scope.getInventorys(); + }, + function (response) { + $scope.deleteInventoryLoading = false; + $scope.err_msg = response.data; + }) + }; + + $scope.addGroup = function (group) { + + $scope.inventory_data_json.groups.push(group); + $scope.selectedInventory.content = ansible.jsonToAnsibleInventoryIni($scope.inventory_data_json); + // To refresh All Hosts list + $scope.inventory_data_json = ansible.parseINIString($scope.selectedInventory.content) + }; + + $scope.addHost = function (host) { + if ($scope.inventory_data_json.hosts.indexOf(host.name) < 0) + $scope.inventory_data_json.hosts.push(host.name); + + var host_member_of_groups = host.members.split(','); + + angular.forEach($scope.inventory_data_json.groups, function (group) { + if ((host_member_of_groups.indexOf(group.name)) > -1 && group.members.indexOf(host.name) < 0) { + group.members.push(host.name) + } + }); + + $scope.selectedInventory.content = ansible.jsonToAnsibleInventoryIni($scope.inventory_data_json); + // To refresh All Hosts list + $scope.inventory_data_json = ansible.parseINIString($scope.selectedInventory.content) + }; + + + $scope.deleteGroup = function (index) { + $scope.inventory_data_json.groups.splice(index, 1); + $scope.selectedInventory.content = ansible.jsonToAnsibleInventoryIni($scope.inventory_data_json); + // To refresh All Hosts list + $scope.inventory_data_json = ansible.parseINIString($scope.selectedInventory.content); + + $scope.saveInventory(); + + }; + + $scope.deleteHost = function (index, group) { + + var hostname = $scope.inventory_data_json.hosts[index]; + + $scope.inventory_data_json.hosts.splice(index, 1); + + angular.forEach($scope.inventory_data_json.groups, function (group) { + var memberIndex = group.members.indexOf(hostname) + if (memberIndex > -1) { + group.members.splice(memberIndex, 1) + } + }); + + $scope.selectedInventory.content = ansible.jsonToAnsibleInventoryIni($scope.inventory_data_json); + // To refresh All Hosts list + $scope.inventory_data_json = ansible.parseINIString($scope.selectedInventory.content); + + $scope.saveInventory(); + }; + + + var getHostMemberOfGroups = function (host) { + var groups = []; + angular.forEach($scope.inventory_data_json.groups, function (group) { + var memberIndex = group.members.indexOf(host); + if (memberIndex > -1) { + groups.push(group.name) + } + }); + return groups; + }; + } +} + +export default angular.module('webAppApp.inventory', [uiRouter]) + .config(routes) + .component('inventory', { + template: require('./inventory.html'), + controller: InventoryComponent, + controllerAs: 'inventoryCtrl' + }) + .name; diff --git a/client/app/designer/inventory/inventory.component.spec.js b/client/app/designer/inventory/inventory.component.spec.js new file mode 100644 index 0000000..8e9e893 --- /dev/null +++ b/client/app/designer/inventory/inventory.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: InventoryComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.inventory')); + + var InventoryComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + InventoryComponent = $componentController('inventory', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/inventory/inventory.css b/client/app/designer/inventory/inventory.css new file mode 100644 index 0000000..e69de29 diff --git a/client/app/designer/inventory/inventory.html b/client/app/designer/inventory/inventory.html new file mode 100644 index 0000000..18dfbea --- /dev/null +++ b/client/app/designer/inventory/inventory.html @@ -0,0 +1,154 @@ +
+ + + +
+ + + + + + + + + + + + + +
SelectName
+ {{inventory}}
+
+ +
+
+ +
+
+
Groups - {{inventory_data_json.groups.length}}
+
+ +
+ + + + + + + + + + + + + + + + + +
SelectNameMembersActions
+ {{group.name}}{{group.members.length}} +
+ + +
+
+
+ +
+
+
+ +
+
+
Group Members - {{selectedGroup.group.name}}
+
+ +
+ + + + + + + + + + + + + + + + +
SelectNameActions
+ {{host}} +
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+
Hosts - {{inventory_data_json.hosts.length}}
+
+ +
+ + + + + + + + + + + + + + + + +
SelectNameActions
+ {{host}} +
+ + +
+
+
+ +
+
+
+ + +
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/client/app/designer/inventory/inventory.routes.js b/client/app/designer/inventory/inventory.routes.js new file mode 100644 index 0000000..17e508f --- /dev/null +++ b/client/app/designer/inventory/inventory.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('designer.inventory', { + url: '/inventory', + template: '' + }); +} diff --git a/client/app/designer/inventory/new_group/new_group.controller.js b/client/app/designer/inventory/new_group/new_group.controller.js new file mode 100644 index 0000000..be566f4 --- /dev/null +++ b/client/app/designer/inventory/new_group/new_group.controller.js @@ -0,0 +1,82 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newGroupController($scope,$uibModalInstance, yamlFile, ansible, selectedProject, editGroup, YAML, $timeout) { + 'ngInject'; + $scope.newGroup = editGroup || {name:null}; + $scope.variableViewType = {type:'YAML'}; + + $scope.complexVar = {}; + $scope.complexVarString = {}; + + console.log("$scope.newGroup.name" + $scope.newGroup.name); + + $scope.aceLoaded = function (_editor) { + _editor.$blockScrolling = Infinity; + }; + + if($scope.newGroup.members){ + $scope.newGroup.members = $scope.newGroup.members.join(','); + } + + if($scope.newGroup.name){ + $scope.getGroupLoading = true; + ansible.getGroupVarsFile($scope.newGroup.name, + function(response){ + $scope.getGroupLoading = false; + $scope.complexVarStringYaml = response.data; + $scope.complexVar = YAML.parse(response.data); + + $timeout(function(){ + $scope.$broadcast('membersUpdated') + },100); + + },function(response){ + $scope.getGroupLoading = false; + $scope.err_msg = response.data; + }) + } + + $scope.$watch('complexVar',function(){ + $scope.complexVarString = JSON.stringify($scope.complexVar, null, '\t'); + $scope.complexVarStringYaml = yamlFile.jsonToYamlFile($scope.complexVar, 'Group Variables - ' + $scope.newGroup.name); + }, true); + + $scope.$watch('newGroup',function(){ + $scope.complexVarString = JSON.stringify($scope.complexVar, null, '\t'); + $scope.complexVarStringYaml = yamlFile.jsonToYamlFile($scope.complexVar, 'Group Variables - ' + $scope.newGroup.name); + }, true); + + $scope.createGroup = function(){ + $scope.createGroupLoading = true; + ansible.updateGroupVarsFile($scope.newGroup.name,$scope.complexVarStringYaml, + function(response){ + $scope.createGroupLoading = false; + console.log("Success"); + $scope.ok() + },function(response){ + $scope.createGroupLoading = false; + $scope.err_msg = response.data; + }); + }; + + $scope.ok = function () { + var resultGroup = {name:$scope.newGroup.name}; + + if($scope.newGroup.members){ + resultGroup.members = $scope.newGroup.members.split(','); + $scope.newGroup.members = $scope.newGroup.members.split(',') + } + + $uibModalInstance.close(resultGroup); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.new_group', []) + .controller('NewGroupController', newGroupController) + .name; diff --git a/client/app/designer/inventory/new_group/new_group.controller.spec.js b/client/app/designer/inventory/new_group/new_group.controller.spec.js new file mode 100644 index 0000000..85bd896 --- /dev/null +++ b/client/app/designer/inventory/new_group/new_group.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewGroupCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_group')); + + var NewGroupCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewGroupCtrl = $controller('NewGroupCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/inventory/new_group/new_group.html b/client/app/designer/inventory/new_group/new_group.html new file mode 100644 index 0000000..62d0b49 --- /dev/null +++ b/client/app/designer/inventory/new_group/new_group.html @@ -0,0 +1,45 @@ + + diff --git a/client/app/designer/inventory/new_host/new_host.controller.js b/client/app/designer/inventory/new_host/new_host.controller.js new file mode 100644 index 0000000..9386039 --- /dev/null +++ b/client/app/designer/inventory/new_host/new_host.controller.js @@ -0,0 +1,69 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newHostController($scope,$uibModalInstance, yamlFile, ansible, selectedProject, editHost, YAML, $timeout) { + $scope.newHost = editHost || {name:null}; + + $scope.variableViewType = {type:'YAML'}; + + $scope.complexVar = {}; + $scope.complexVarString = {}; + + if($scope.newHost.name){ + $scope.getHostLoading = true; + ansible.getHostVarsFile($scope.newHost.name, + function(response){ + $scope.getHostLoading = false; + $scope.complexVarStringYaml = response.data; + $scope.complexVar = YAML.parse(response.data); + $timeout(function(){ + $scope.$broadcast('membersUpdated') + },100); + + },function(response){ + $scope.getHostLoading = false; + $scope.err_msg = response.data; + }) + } + + $scope.$watch('complexVar',function(){ + $scope.complexVarString = JSON.stringify($scope.complexVar, null, '\t'); + $scope.complexVarStringYaml = yamlFile.jsonToYamlFile($scope.complexVar, 'Host Variables - ' + $scope.newHost.name); + }, true); + + $scope.$watch('newHost',function(){ + $scope.complexVarString = JSON.stringify($scope.complexVar, null, '\t'); + $scope.complexVarStringYaml = yamlFile.jsonToYamlFile($scope.complexVar, 'Host Variables - ' + $scope.newHost.name); + }, true); + + $scope.aceLoaded = function(_editor){ + _editor.$blockScrolling = Infinity; + }; + + $scope.createHost = function(){ + $scope.createHostLoading = true; + ansible.updateHostVarsFile($scope.newHost.name,$scope.complexVarStringYaml, + function(response){ + $scope.createHostLoading = false; + console.log("Success"); + $scope.ok() + },function(response){ + $scope.createHostLoading = false; + $scope.err_msg = response.data; + }); + }; + + $scope.ok = function () { + if(!$scope.newHost.members)$scope.newHost.members = 'Un grouped'; + $uibModalInstance.close($scope.newHost); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.new_host', []) + .controller('NewHostController', newHostController) + .name; diff --git a/client/app/designer/inventory/new_host/new_host.controller.spec.js b/client/app/designer/inventory/new_host/new_host.controller.spec.js new file mode 100644 index 0000000..218aa92 --- /dev/null +++ b/client/app/designer/inventory/new_host/new_host.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewHostCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_host')); + + var NewHostCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewHostCtrl = $controller('NewHostCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/inventory/new_host/new_host.html b/client/app/designer/inventory/new_host/new_host.html new file mode 100644 index 0000000..fbf7cc0 --- /dev/null +++ b/client/app/designer/inventory/new_host/new_host.html @@ -0,0 +1,46 @@ + + diff --git a/client/app/designer/inventory/new_inventory/new_inventory.controller.js b/client/app/designer/inventory/new_inventory/new_inventory.controller.js new file mode 100644 index 0000000..4510f2c --- /dev/null +++ b/client/app/designer/inventory/new_inventory/new_inventory.controller.js @@ -0,0 +1,40 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newInventoryController($scope,$uibModalInstance,ansible,selectedProject) { + $scope.newInventory = {name:null}; + + $scope.createInventoryLoading = false; + + $scope.createInventory = function(){ + + if($scope.newInventory.name.match(/\./)){ + $scope.err_msg = "Inventory files should not have extension" + return + } + + $scope.createInventoryLoading = true; + ansible.createInventory($scope.newInventory.name,'# Inventory File - ' + $scope.newInventory.name, + function(response){ + $scope.createInventoryLoading = false; + $scope.ok(); + }, + function(response){ + $scope.createInventoryLoading = false; + $scope.err_msg = response.data; + }) + }; + + $scope.ok = function () { + $uibModalInstance.close(null); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.new_inventory', []) + .controller('NewInventoryController', newInventoryController) + .name; diff --git a/client/app/designer/inventory/new_inventory/new_inventory.controller.spec.js b/client/app/designer/inventory/new_inventory/new_inventory.controller.spec.js new file mode 100644 index 0000000..df0c54e --- /dev/null +++ b/client/app/designer/inventory/new_inventory/new_inventory.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewInventoryCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_inventory')); + + var NewInventoryCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewInventoryCtrl = $controller('NewInventoryCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/inventory/new_inventory/new_inventory.html b/client/app/designer/inventory/new_inventory/new_inventory.html new file mode 100644 index 0000000..c4ececc --- /dev/null +++ b/client/app/designer/inventory/new_inventory/new_inventory.html @@ -0,0 +1,21 @@ + + diff --git a/client/app/designer/playbook/new_play/new_play.controller.js b/client/app/designer/playbook/new_play/new_play.controller.js new file mode 100644 index 0000000..23736ac --- /dev/null +++ b/client/app/designer/playbook/new_play/new_play.controller.js @@ -0,0 +1,134 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newPlayController($scope, $uibModalInstance, ansible, plays, selectedPlayIndex) { + $scope.loading_msg = ''; + $scope.title = "Create Play"; + $scope.editMode = false; + $scope.editHostMode = false; + + var selectedPlay; + if(selectedPlayIndex > -1){ + selectedPlay = plays[selectedPlayIndex]; + $scope.title = "Edit Play"; + $scope.editMode = true; + if(selectedPlay && selectedPlay.tags)$scope.tags = selectedPlay.tags.join(','); + } + + $scope.newPlay = selectedPlay || {}; + + $scope.newPlay_roles = $scope.newPlay.roles; + + $scope.createPlayLoading = false; + + $scope.createPlay = function () { + $scope.ok($scope.newPlay) + }; + + $scope.ok = function (newPlay) { + if($scope.tags) + newPlay.tags = $scope.tags.split(','); + + + if($scope.newPlay_roles && $scope.newPlay_roles.length){ + var roles = []; + angular.forEach($scope.newPlay_roles,function(role){ + roles.push(role.text) + }); + newPlay.roles = roles; + }else if(newPlay.roles){ + delete newPlay.roles; + } + + $uibModalInstance.close(newPlay); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; + + + $scope.getHostsFromInventory = function(){ + + var hosts = []; + + angular.forEach($scope.inventory_data_json.hosts, function(host){ + hosts.push({name:host}) + }); + + angular.forEach($scope.inventory_data_json.groups, function(group){ + if(group.name !== 'Un grouped') + hosts.push({name:group.name}) + }); + + return hosts; + }; + + $scope.listOfInventoryFiles = function(){ + $scope.loading_msg = 'Loading Inventory Files'; + ansible.getInventoryList(function(response){ + $scope.loading_msg = ''; + $scope.inventoryFiles = response.data; + }, + function(response){ + $scope.loading_msg = ''; + $scope.err_msg = response.data + }) + }; + + + $scope.listOfRoles = function(){ + $scope.loading_msg = 'Loading Roles'; + ansible.getRoleList(function(response){ + $scope.loading_msg = ''; + $scope.roleList = response.data; + }, + function(response){ + $scope.loading_msg = ''; + $scope.err_msg = response.data + }) + }; + + $scope.inventorySelected = function(selectedInventoryFile){ + $scope.loading_msg = 'Loading Hosts'; + ansible.readInventory(selectedInventoryFile, + function(response){ + $scope.loading_msg = ''; + $scope.inventory_data_json = ansible.parseINIString(response.data); + $scope.hosts = $scope.getHostsFromInventory(); + + },function(response){ + $scope.loading_msg = ''; + $scope.err_msg = response.data + }) + }; + + $scope.getHostObject = function(hostname){ + var result = $scope.hosts.filter(function(host){ + return host.name == hostname + }); + + if(result.length){ + return result[0] + } + + }; + + $scope.listOfInventoryFiles(); + $scope.listOfRoles(); + + $scope.loadTags = function(query){ + if($scope.roleList){ + var tempList = $scope.roleList.filter(function(role){ + return role.indexOf(query) > -1 + }); + + return tempList + } + } +} + +export default angular.module('webAppApp.new_play', []) + .controller('NewPlayController', newPlayController) + .name; diff --git a/client/app/designer/playbook/new_play/new_play.controller.spec.js b/client/app/designer/playbook/new_play/new_play.controller.spec.js new file mode 100644 index 0000000..c8b68db --- /dev/null +++ b/client/app/designer/playbook/new_play/new_play.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewPlayCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_play')); + + var NewPlayCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewPlayCtrl = $controller('NewPlayCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/playbook/new_play/new_play.html b/client/app/designer/playbook/new_play/new_play.html new file mode 100644 index 0000000..765a0bf --- /dev/null +++ b/client/app/designer/playbook/new_play/new_play.html @@ -0,0 +1,96 @@ + + diff --git a/client/app/designer/playbook/new_playbook/new_playbook.controller.js b/client/app/designer/playbook/new_playbook/new_playbook.controller.js new file mode 100644 index 0000000..9f045bb --- /dev/null +++ b/client/app/designer/playbook/new_playbook/new_playbook.controller.js @@ -0,0 +1,35 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newPlaybookController($scope,$uibModalInstance,ansible) { + $scope.newPlaybook = {name:null}; + + $scope.createPlaybookLoading = false; + + $scope.createPlaybook = function(){ + $scope.createPlaybookLoading = true; + + ansible.createPlaybook($scope.newPlaybook.name + '.yml',"", + function(response){ + $scope.createPlaybookLoading = false; + $scope.ok(); + }, + function(response){ + $scope.createPlaybookLoading = false; + $scope.err_msg = response.data; + }) + }; + + $scope.ok = function () { + $uibModalInstance.close(null); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.new_playbook', []) + .controller('NewPlaybookController', newPlaybookController) + .name; diff --git a/client/app/designer/playbook/new_playbook/new_playbook.controller.spec.js b/client/app/designer/playbook/new_playbook/new_playbook.controller.spec.js new file mode 100644 index 0000000..1d3cc3c --- /dev/null +++ b/client/app/designer/playbook/new_playbook/new_playbook.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewPlaybookCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_playbook')); + + var NewPlaybookCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewPlaybookCtrl = $controller('NewPlaybookCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/playbook/new_playbook/new_playbook.html b/client/app/designer/playbook/new_playbook/new_playbook.html new file mode 100644 index 0000000..a1a0962 --- /dev/null +++ b/client/app/designer/playbook/new_playbook/new_playbook.html @@ -0,0 +1,19 @@ + + diff --git a/client/app/designer/playbook/playbook.component.js b/client/app/designer/playbook/playbook.component.js new file mode 100644 index 0000000..0ebd39a --- /dev/null +++ b/client/app/designer/playbook/playbook.component.js @@ -0,0 +1,328 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './playbook.routes'; + +export class PlaybookComponent { + /*@ngInject*/ + constructor($scope,$uibModal,YAML,ansible,yamlFile) { + 'ngInject'; + $scope.isopen = {playbooks:true,plays:false,tasks:false}; + + $scope.selectedPlaybook = {playbook: "",content: ""}; + $scope.selectedPlay = {play: ""}; + + $scope.showSaveButton = {}; + $scope.loadingButtons = {}; + + $scope.editPlaybook = {value:false}; + + $scope.loadingModuleCode = false; + + $scope.$on('projectLoaded',function(){ + $scope.getPlaybooks() + }); + + //To fix a warning message in console + $scope.aceLoaded = function(_editor){ + _editor.$blockScrolling = Infinity; + }; + + // --------------------------------------- PLAYBOOKS ---------------- + + $scope.getPlaybooks = function(){ + ansible.getPlaybookList( + function(response){ + $scope.playbooks = response.data; + }, + function(response){ + console.log(response.data) + } + ) + }; + + + if($scope.$parent.selectedProject && $scope.$parent.selectedProject.ansibleEngine){ + $scope.getPlaybooks() + } + + $scope.showPlaybookCode = function(playbook_name){ + $scope.loadingModuleCode = true; + + if(!playbook_name){ + $scope.selectedPlaybook.content = "Select a module"; + return; + } + ansible.readPlaybook(playbook_name,function(response) { + $scope.isopen.playbooks = true; + $scope.isopen.plays = true + $scope.loadingModuleCode = false; + $scope.selectedPlaybook.content = response.data.split("Stream :: close")[0]; + $scope.getPlaysFromPlayBook($scope.selectedPlaybook.content); + + }); + }; + + $scope.$watch('selectedPlaybook.playbook',function(newValue,oldValue){ + if(newValue && newValue !== oldValue){ + $scope.selectedPlaybook.content = "Loading Code..."; + $scope.showPlaybookCode(newValue); + } + }); + + $scope.$watch('selectedPlay.play',function(newValue,oldValue){ + if(newValue && newValue !== oldValue){ + $scope.selectedPlay.play.tasks = $scope.selectedPlay.play.tasks || []; + $scope.isopen.playbooks = false; + $scope.isopen.plays = false; + $scope.isopen.tasks = true; + $scope.isopen.roles = true; + } + }); + + + $scope.showCreatePlaybookModal = function(){ + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/playbook/new_playbook/new_playbook.html', + controller: 'NewPlaybookController', + size: 'md', + backdrop : 'static', + keyboard : false, + closeByEscape : false, + closeByDocument : false, + resolve: { + selectedProject: function(){ + return $scope.$parent.selectedProject + } + } + }); + + modalInstance.result.then(function () { + $scope.getPlaybooks(); + }, function () { + + }); + + }; + + $scope.editPlaybookMethod = function(){ + $scope.editPlaybook.value = true; + $scope.uneditedPlaybokContents = $scope.selectedPlaybook.content + }; + + $scope.cancelPlaybookChanges = function(){ + $scope.editPlaybook.value = false; + $scope.selectedPlaybook.content = $scope.uneditedPlaybokContents + }; + + $scope.savePlaybook = function(buttonVariable){ + console.log("Saving Playbook") + $scope.loadingButtons[buttonVariable] = true; + + ansible.createPlaybook($scope.selectedPlaybook.playbook,$scope.selectedPlaybook.content, + function(response){ + $scope.loadingButtons[buttonVariable] = false; + $scope.showSaveButton[buttonVariable] = false; + $scope.editPlaybook.value = false; + }, + function(response){ + $scope.loadingButtons[buttonVariable] = false; + $scope.showSaveButton[buttonVariable] = false; + $scope.err_msg = response.data; + }) + }; + + $scope.deletePlaybook = function(){ + $scope.deletePlaybookLoading = true; + ansible.deletePlaybook($scope.selectedPlaybook.playbook, + function(response){ + $scope.deletePlaybookLoading = false; + $scope.selectedPlaybook.playbook = ""; + $scope.getPlaybooks(); + }, + function(response){ + $scope.deletePlaybookLoading = false; + $scope.err_msg = response.data; + }) + }; + + + //--------------- PLAY -------------- + + $scope.showCreatePlayModal = function(selectedPlayIndex){ + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createPlayContent.html',*/ + templateUrl: 'app/designer/playbook/new_play/new_play.html', + controller: 'NewPlayController', + size: 'lg', + backdrop : 'static', + keyboard : false, + closeByEscape : false, + closeByDocument : false, + resolve: { + selectedProject: function () { + return $scope.$parent.selectedProject; + }, + plays: function () { + return $scope.plays; + }, + selectedPlayIndex: function () { + return selectedPlayIndex; + } + } + }); + + modalInstance.result.then( + function (newPlay) { + if(selectedPlayIndex == null) + $scope.plays.push(newPlay); + + $scope.clearEmptyTasks($scope.plays); + + $scope.selectedPlaybook.content = yamlFile.jsonToYamlFile($scope.plays, 'Playbook file: ' + $scope.selectedPlaybook.playbook) + $scope.savePlaybook(); + }, function () { + + }); + + }; + + + // FUNCTION - GET PLAYS FROM PLAYBOOK + + $scope.getPlaysFromPlayBook = function(playbookYamlData){ + $scope.plays = YAML.parse(playbookYamlData) || [] + }; + + // FUNCTION - DELETE PLAY + + $scope.deletePlay = function(index){ + $scope.plays.splice(index,1); + $scope.selectedPlaybook.content = yamlFile.jsonToYamlFile($scope.plays, 'Playbook file: ' + $scope.selectedPlaybook.playbook) + $scope.savePlaybook(); + $scope.selectedPlay = {play: ""}; + }; + + // ------------------- EXECUTE PLAYBOOK MODAL ------------- + + $scope.executeAnsiblePlayBook = function(tags,executionType,executionName,selectedPlay){ + console.log("Tags type" + typeof tags) + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/execution/executeModal.html', + controller: 'ExecutionController', + size: 'lg', + backdrop : 'static', + keyboard : false, + closeByEscape : false, + closeByDocument : false, + resolve: { + tags: function(){ + return tags + }, + selectedProject: function(){ + return $scope.$parent.selectedProject + }, + selectedPlaybook: function(){ + return $scope.selectedPlaybook + }, + selectedPlay: function(){ + return selectedPlay + }, + executionType: function(){ + return executionType + }, + executionName: function(){ + return executionName + }, + readOnly: function(){ + return false + }, + runData: function(){ + return null + }, + projectFolder: function(){ + return null + }, + roleName: function(){ + return null + } + } + }); + }; + + + $scope.clearEmptyTasks = function(plays){ + //Check for empty tasks list + angular.forEach(plays,function(play){ + if((play.tasks && !play.tasks.length) || !play.tasks){ + delete play.tasks + } + }); + + }; + + // ---------------------- TASKS ------------------- + + $scope.updatePlaybookFileContent = function(save,buttonVariable){ + + var playsCopy = angular.copy($scope.plays); + + $scope.clearEmptyTasks(playsCopy); + + $scope.selectedPlaybook.content = yamlFile.jsonToYamlFile(playsCopy, 'Playbook file: ' + $scope.selectedPlaybook.playbook) + if(save) + $scope.savePlaybook(buttonVariable); + }; + + $scope.moveUp = function(list,index,buttonVariable){ + if(!$scope.preChangeData) $scope.preChangeData = angular.copy(list); + var temp = angular.copy(list[index]); + list[index] = list[index-1]; + list[index-1] = temp; + + $scope.updatePlaybookFileContent(false); + + $scope.showSaveButton[buttonVariable] = true + + }; + + $scope.cancelChange = function(buttonVariable){ + if($scope.preChangeData){ + $scope.plays = angular.copy($scope.preChangeData); + $scope.preChangeData = null + + } + $scope.updatePlaybookFileContent(false); + + $scope.showSaveButton[buttonVariable] = false + }; + + $scope.moveDown = function(list,index,buttonVariable){ + if(!$scope.preChangeData) $scope.preChangeData = angular.copy(list); + var temp = angular.copy(list[index]); + list[index] = list[index+1]; + list[index+1] = temp; + + $scope.updatePlaybookFileContent(false); + + $scope.showSaveButton[buttonVariable] = true + + }; + } +} + +export default angular.module('webAppApp.playbook', [uiRouter]) + .config(routes) + .component('playbook', { + template: require('./playbook.html'), + controller: PlaybookComponent, + controllerAs: 'playbookCtrl' + }) + .name; diff --git a/client/app/designer/playbook/playbook.component.spec.js b/client/app/designer/playbook/playbook.component.spec.js new file mode 100644 index 0000000..5e5977f --- /dev/null +++ b/client/app/designer/playbook/playbook.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: PlaybookComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.playbook')); + + var PlaybookComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + PlaybookComponent = $componentController('playbook', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/playbook/playbook.css b/client/app/designer/playbook/playbook.css new file mode 100644 index 0000000..e69de29 diff --git a/client/app/designer/playbook/playbook.html b/client/app/designer/playbook/playbook.html new file mode 100644 index 0000000..72b1ae1 --- /dev/null +++ b/client/app/designer/playbook/playbook.html @@ -0,0 +1,166 @@ +
+ +
+ + Playbooks + + + + + + + + +
+ + + + + + + + + + + + + + + +
SelectName
+ {{playbook}}
+
+
+ +
+ + Plays + + +
+ + + + + + + + + + + + + + + + + + + + +
SelectNameHostsActions
+ {{play.name}}{{play.hosts}} +
+ + +
+ + +
+
+
+ + + + +
+ +
+ + Tasks + + + +
+ +
+ + Roles + +
+ + + + + + + + + + + + + +
SelectName
+ {{role}}
+
+ +
+
+
File Browser
+
+ + + {{node.name}} + +
+
+
+
+ + +
+ +
+ +
+
+ +
+ +
diff --git a/client/app/designer/playbook/playbook.routes.js b/client/app/designer/playbook/playbook.routes.js new file mode 100644 index 0000000..13f26ff --- /dev/null +++ b/client/app/designer/playbook/playbook.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('designer.playbook', { + url: '/playbook', + template: '' + }); +} diff --git a/client/app/designer/roles/new_file/new_file.controller.js b/client/app/designer/roles/new_file/new_file.controller.js new file mode 100644 index 0000000..7872728 --- /dev/null +++ b/client/app/designer/roles/new_file/new_file.controller.js @@ -0,0 +1,58 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newFileController($scope,$uibModalInstance,ansible,selectedDirectory,copyFile,selectedFileName) { + $scope.newFile = {name:null}; + $scope.createFileLoading = false; + $scope.title = 'New File'; + + var parentDirectory = selectedDirectory; + + // If copyFile use selectedFileName to create new role from + // else nullify selectedFileName + if(!copyFile){ + selectedFileName = null; + } + else { + $scope.title = 'Copy File'; + $scope.newFile.name = 'Copy of ' + selectedFileName; + } + + /** + * Create/Copy File - Either a new role or copy an existing role + */ + $scope.createFile = function(){ + + $scope.createFileLoading = true; + ansible.createFile(parentDirectory + '/' + $scope.newFile.name, + function(response){ + $scope.createFileLoading = false; + $scope.ok(); + }, + function(response){ + $scope.createFileLoading = false; + $scope.err_msg = response.data; + }, + selectedFileName + ) + }; + + /** + * Close create/copy modal + */ + $scope.ok = function () { + $uibModalInstance.close(null); + }; + + /** + * Cancel modal + */ + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.new_file', []) + .controller('NewFileController', newFileController) + .name; diff --git a/client/app/designer/roles/new_file/new_file.controller.spec.js b/client/app/designer/roles/new_file/new_file.controller.spec.js new file mode 100644 index 0000000..4a488a9 --- /dev/null +++ b/client/app/designer/roles/new_file/new_file.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewFileCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_file')); + + var NewFileCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewFileCtrl = $controller('NewFileCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/roles/new_file/new_file.html b/client/app/designer/roles/new_file/new_file.html new file mode 100644 index 0000000..daec11c --- /dev/null +++ b/client/app/designer/roles/new_file/new_file.html @@ -0,0 +1,19 @@ + + diff --git a/client/app/designer/roles/new_role/new_role.controller.js b/client/app/designer/roles/new_role/new_role.controller.js new file mode 100644 index 0000000..a1fcf40 --- /dev/null +++ b/client/app/designer/roles/new_role/new_role.controller.js @@ -0,0 +1,56 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function newRoleController($scope,$uibModalInstance,ansible,selectedRoleName,copyRole) { + + $scope.newRole = {name:null}; + $scope.createRoleLoading = false; + $scope.title = 'New Role'; + + // If copyRole use selectedRoleName to create new role from + // else nullify selectedRoleName + if(!copyRole){ + selectedRoleName = null; + } + else { + $scope.title = 'Copy Role'; + $scope.newRole.name = 'Copy of ' + selectedRoleName; + } + + /** + * Create/Copy Role - Either a new role or copy an existing role + */ + $scope.createRole = function(){ + $scope.createRoleLoading = true; + ansible.createRole($scope.newRole.name, + function(response){ + $scope.createRoleLoading = false; + $scope.ok(); + }, + function(response){ + $scope.createRoleLoading = false; + $scope.err_msg = response.data; + }, + selectedRoleName + ) + }; + + /** + * Close create/copy modal + */ + $scope.ok = function () { + $uibModalInstance.close(null); + }; + + /** + * Cancel modal + */ + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.new_role', []) + .controller('NewRoleController', newRoleController) + .name; diff --git a/client/app/designer/roles/new_role/new_role.controller.spec.js b/client/app/designer/roles/new_role/new_role.controller.spec.js new file mode 100644 index 0000000..bda468b --- /dev/null +++ b/client/app/designer/roles/new_role/new_role.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewRoleCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_role')); + + var NewRoleCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewRoleCtrl = $controller('NewRoleCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/roles/new_role/new_role.html b/client/app/designer/roles/new_role/new_role.html new file mode 100644 index 0000000..b4bcba8 --- /dev/null +++ b/client/app/designer/roles/new_role/new_role.html @@ -0,0 +1,19 @@ + + diff --git a/client/app/designer/roles/roles.component.js b/client/app/designer/roles/roles.component.js new file mode 100644 index 0000000..f11827f --- /dev/null +++ b/client/app/designer/roles/roles.component.js @@ -0,0 +1,463 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './roles.routes'; + +export class RolesComponent { + /*@ngInject*/ + constructor($scope, ansible, $uibModal, yamlFile, Projects, editor) { + 'ngInject'; + + $scope.treeOptions = { + nodeChildren: "children", + dirSelectable: true, + isLeaf: function (node) { + return !(node.type === 'directory'); + }, + injectClasses: { + ul: "a1", + li: "a2", + liSelected: "a7", + iExpanded: "a3", + iCollapsed: "a4", + iLeaf: "a5", + label: "a6", + labelSelected: "a8" + } + }; + + $scope.isopen = {roles: true, filebrowser: false, tasks: false}; + + $scope.selectedRole = {role: "", tasks: null}; + + $scope.selectedFile = {showSource: true, markdownContent: true, content: ""}; + + $scope.editRole = {value: false}; + $scope.showSaveFileButton = false; + + $scope.$on('projectLoaded', function () { + $scope.getRoles() + }); + + $scope.aceLoaded = function (_editor) { + _editor.$blockScrolling = Infinity; + }; + + // --------------------------------------- PLAYBOOKS ---------------- + + $scope.getRoles = function () { + ansible.getRoleList( + function (response) { + $scope.roles = response.data; + if(localStorage.selectedRoleName) + $scope.selectedRole.role = localStorage.selectedRoleName + }, + function (response) { + console.log(response.data) + } + ) + }; + + var getRoleByName = function(roleName){ + var result = null; + angular.forEach($scope.roles,function(role){ + if(role.name == roleName){ + result = role + } + }); + return result; + }; + + if ($scope.$parent.selectedProject && $scope.$parent.selectedProject.ansibleEngine) { + $scope.getRoles() + } + + $scope.loadingModuleCode = false; + + $scope.markdownContent = ""; + + $scope.showRoleCode = function (role_name) { + $scope.loadingModuleCode = true; + $scope.markdownContent = ''; + $scope.docType = 'text'; + $scope.selectedFile.content = 'Loading Role Files..'; + $scope.selectedRole.tasks = null; + $scope.roleData = null; + + if (!role_name) { + $scope.selectedFile.content = "Select a module"; + return; + } + ansible.getRoleFiles(role_name, function (response) { + $scope.loadingModuleCode = false; + $scope.selectedFile.content = JSON.stringify(response.data, null, '\t'); + $scope.docType = 'json'; + $scope.roleData = response.data; + + }); + }; + + $scope.$watch('selectedRole.role', function (newValue, oldValue) { + if (newValue && newValue !== oldValue) { + $scope.currentRole = newValue; + $scope.reloadRole(); + //$scope.isopen.roles = false; + $scope.isopen.filebrowser = true; + localStorage.selectedRoleName = $scope.selectedRole.role; + } + }); + + $scope.reloadRole = function () { + $scope.selectedFile.content = "Loading Code..."; + $scope.docType = 'txt'; + $scope.showRoleCode($scope.currentRole); + }; + + + + /*var setDocType = function (data, file) { + if (typeof data == 'object') { + $scope.selectedFile.content = JSON.stringify(data, null, '\t'); + } else { + $scope.selectedFile.content = data; + } + + $scope.docType = editor.ui_ace_doctype_map[file.extension.replace('.', '')]; + + if ($scope.docType == 'markdown') { + $scope.markdownContent = $scope.selectedFile.content; + $scope.selectedFile.showSource = false; + } + };*/ + + /** + * Show selected item in the tree + * @param file + * @param parent + */ + $scope.showSelected = function (file, parent, decrypt) { + + if($scope.editRole.value){ + return + } + + + if (file.children) { + $scope.selectedFile.content = JSON.stringify(file, null, '\t'); + $scope.docType = 'json'; + $scope.selectedRole.tasks = null; + return; + } + + $scope.selectedFile.content = 'Loading..'; + + var command = 'cat "' + file.path + '"'; + $scope.encryptedFile = false; + if(decrypt){ + command = 'ansible-vault view "' + file.path + '" --vault-password-file ~/.vault_pass.txt' + $scope.encryptedFile = true; + $scope.selectedFile.content = 'Loading Encrypted File..'; + } + + $scope.selectedFile.showSource = true; + $scope.markdownContent = ''; + $scope.docType = 'text'; + + $scope.selectedRole.tasks = null; + $scope.selectedFileName = file.name; + $scope.selectedFilePath = file.path; + $scope.parentNode = parent; + + ansible.executeCommand(command, + function (response) { + $scope.preChangeData = null; + editor.setContentAndType(response.data, file, $scope.selectedFile); + + var parentDirectory = file.path.replace(/^(.+)\/(.+)\/([^/]+)$/, "$2"); + if (parentDirectory == 'tasks') { + $scope.selectedRole.tasks = YAML.parse(response.data) || []; + $scope.isopen.tasks = true; + $scope.isopen.roles = false; + } + + if(response.data.indexOf('ANSIBLE_VAULT') > -1){ + editor.setContentAndType('Decrypting content...', file, $scope.selectedFile); + $scope.showSelected(file, parent, true); + } + + }, function (response) { + $scope.selectedFile.content = response.data; + + }) + }; + + $scope.showCreateFileModal = function (selectedFile, copyFile) { + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/roles/new_file/new_file.html', + controller: 'NewFileController', + size: 'md', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + copyFile: function () { + return copyFile + }, + selectedDirectory: function () { + if (selectedFile.type == 'directory') + return selectedFile.path; + else return $scope.parentNode.path + }, + selectedFileName: function () { + return selectedFile + } + } + }); + + modalInstance.result.then(function () { + //$scope.getRoles(); + $scope.reloadRole(); + }, function () { + + }); + }; + + $scope.showCreateRoleModal = function (copyRole) { + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/roles/new_role/new_role.html', + controller: 'NewRoleController', + size: 'md', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + copyRole: function () { + return copyRole + }, + selectedRoleName: function () { + return $scope.selectedRole.role + } + } + }); + + modalInstance.result.then(function () { + $scope.getRoles(); + }, function () { + + }); + + }; + + $scope.showSearchRoleModal = function () { + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/roles/search_role/search_role.html', + controller: 'SearchRoleController', + size: 'lg', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + selectedProject: function () { + return $scope.$parent.selectedProject + } + } + }); + + modalInstance.result.then(function () { + $scope.getRoles(); + }, function () { + + }); + + }; + + + $scope.saveRole = function () { + $scope.saveRoleLoading = true; + ansible.createRole($scope.selectedRole.role, $scope.selectedFile.content, + function (response) { + $scope.saveRoleLoading = false; + $scope.editRole.value = false; + }, + function (response) { + $scope.saveRoleLoading = false; + $scope.err_msg = response.data; + }) + }; + + $scope.deleteRole = function () { + $scope.deleteRoleLoading = true; + ansible.deleteRole($scope.selectedRole.role, + function (response) { + $scope.deleteRoleLoading = false; + $scope.selectedRole.role = ""; + $scope.selectedFile.content = ""; + $scope.roleData = null; + $scope.getRoles(); + }, + function (response) { + $scope.deleteRoleLoading = false; + $scope.err_msg = response.data; + $scope.selectedFile.content = ""; + $scope.roleData = null; + }) + }; + + $scope.loadingButtons = {}; + $scope.showSaveButton = {}; + + // ------------- PLAYBOOK ------------------ + $scope.saveTasksFile = function (buttonStates) { + + buttonStates = buttonStates || {}; + + buttonStates.loading = true; + var tasksFileContent = $scope.selectedFile.content; + + + ansible.createPlaybook($scope.selectedFilePath, tasksFileContent, + function (response) { + buttonStates.loading = false; + buttonStates.save = false; + }, + function (response) { + buttonStates.loading = false; + buttonStates.save = false; + buttonStates.err_msg = false; + }) + }; + + + $scope.updatePlaybookFileContent = function (save, buttonStates, preChangedData) { + $scope.selectedRole.tasks = preChangedData || $scope.selectedRole.tasks; + $scope.selectedFile.content = yamlFile.jsonToYamlFile($scope.selectedRole.tasks, 'Tasks File: ' + $scope.selectedFileName); + if (save) + $scope.saveTasksFile(buttonStates); + }; + + + $scope.editFile = function (selectedFile) { + if (selectedFile.type == 'directory')return; + + if (!$scope.preChangeData){ + console.log("No prechanged data, setting pre change data"); + $scope.preChangeData = angular.copy($scope.selectedFile.content); + } + + $scope.editRole.value = true; + $scope.showSaveFileButton = true; + + }; + + $scope.cancelFileChanges = function (selectedFile) { + if ($scope.preChangeData) { + console.log("Replacing content with pre changed data"); + $scope.selectedFile.content = angular.copy($scope.preChangeData); + $scope.preChangeData = null; + console.log("Clearing pre changed data") + } + + $scope.editRole.value = false; + $scope.showSaveFileButton = false; + + }; + + $scope.saveFile = function (selectedFile) { + $scope.showSaveFileButtonLoading = true; + $scope.preChangeData = null; + ansible.updateFile(selectedFile.path, $scope.selectedFile.content, + function (response) { + $scope.showSaveFileButtonLoading = false; + $scope.showSaveFileButton = false; + $scope.editRole.value = false; + }, function (error) { + $scope.showSaveFileButtonLoading = false; + $scope.err_msg = error.data; + }) + + }; + + $scope.deleteFile = function (selectedFile) { + ansible.deleteFile(selectedFile.path, function (response) { + $scope.reloadRole(); + }, function (error) { + $scope.showSaveFileButtonLoading = false; + $scope.err_msg = error.data; + }) + }; + // ------------------- EXECUTE PLAYBOOK MODAL ------------- + + $scope.executeAnsiblePlayBook = function (tags, executionType, executionName, selectedPlay) { + console.log("Tags type" + typeof tags); + + var projectRolesFolder = Projects.selectedProject.ansibleEngine.projectFolder + '/roles'; + var rolesFolder = projectRolesFolder + '/' + $scope.selectedRole.role; + var roleName = $scope.selectedRole.role; + console.log("Projects Roles Folder" + projectRolesFolder); + + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/execution/executeModal.html', + controller: 'ExecutionController', + size: 'lg', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + tags: function () { + return tags + }, + selectedProject: function () { + return Projects.selectedProject + }, + selectedPlaybook: function () { + return {playbook: $scope.selectedRole.role + '/tests/test.yml'}; + }, + selectedPlay: function () { + return selectedPlay + }, + executionType: function () { + return executionType + }, + executionName: function () { + return executionName + }, + readOnly: function () { + return false + }, + runData: function () { + return null + }, + projectFolder: function () { + return projectRolesFolder + }, + roleName: function () { + return roleName + } + } + }); + }; + } +} + +export default angular.module('webAppApp.roles', [uiRouter]) + .config(routes) + .component('roles', { + template: require('./roles.html'), + controller: RolesComponent, + controllerAs: 'rolesCtrl' + }) + .name; diff --git a/client/app/designer/roles/roles.component.spec.js b/client/app/designer/roles/roles.component.spec.js new file mode 100644 index 0000000..ab15dcf --- /dev/null +++ b/client/app/designer/roles/roles.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: RolesComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.roles')); + + var RolesComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + RolesComponent = $componentController('roles', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/roles/roles.css b/client/app/designer/roles/roles.css new file mode 100644 index 0000000..e69de29 diff --git a/client/app/designer/roles/roles.html b/client/app/designer/roles/roles.html new file mode 100644 index 0000000..ae532e5 --- /dev/null +++ b/client/app/designer/roles/roles.html @@ -0,0 +1,110 @@ +
+ + +
+ + Roles + + + + + + + + + + +
+ + + + + + + + + + + + + +
SelectName
+ {{role}}
+
+ +
+ +
+ + File Browser {{selectedRole.role ? '-' + selectedRole.role : ''}} + + + + {{node.name}} + + + + + + + + +
+ +
+ + Tasks {{selectedRole.role ? '-' + selectedRole.role : ''}} + + + +
+ +
+ + +
+ + +
+ + + + +
+ +
+ +
+
+ +
diff --git a/client/app/designer/roles/roles.routes.js b/client/app/designer/roles/roles.routes.js new file mode 100644 index 0000000..db7be0e --- /dev/null +++ b/client/app/designer/roles/roles.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('designer.roles', { + url: '/roles', + template: '' + }); +} diff --git a/client/app/designer/roles/search_role/search_role.controller.js b/client/app/designer/roles/search_role/search_role.controller.js new file mode 100644 index 0000000..9ed4e60 --- /dev/null +++ b/client/app/designer/roles/search_role/search_role.controller.js @@ -0,0 +1,66 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function searchRoleController($scope,ansible,selectedProject,$uibModalInstance) { + + $scope.searchText = ''; + $scope.searchLoading = false; + + $scope.selectedRole = {role:{}}; + + + + $scope.searchRoles = function(){ + $scope.searchResult = []; + $scope.searchLoading = true; + ansible.searchRolesGalaxy($scope.searchText, + function(response){ + $scope.searchLoading = false; + $scope.searchResult = $scope.searchResult.concat(response.data) + },function(response){ + $scope.searchLoading = false; + $scope.err_msg = response.data + }); + + ansible.searchRolesGithub($scope.searchText, + function(response){ + $scope.searchLoading = false; + $scope.searchResult = $scope.searchResult.concat(response.data) + },function(response){ + $scope.searchLoading = false; + $scope.err_msg = response.data + }) + + + }; + + + $scope.importRole = function(role){ + $scope.importLoading = true; + + if(role.type === 'galaxy')role.url = role.name; + + ansible.importRole(role.type,role.url, + function(response){ + $scope.importLoading = false; + $scope.ok(); + },function(response){ + $scope.importLoading = false; + $scope.err_msg = response.data + }); + }; + + + $scope.ok = function () { + $uibModalInstance.close(null); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.search_role', []) + .controller('SearchRoleController', searchRoleController) + .name; diff --git a/client/app/designer/roles/search_role/search_role.controller.spec.js b/client/app/designer/roles/search_role/search_role.controller.spec.js new file mode 100644 index 0000000..dec48bb --- /dev/null +++ b/client/app/designer/roles/search_role/search_role.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: SearchRoleCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.search_role')); + + var SearchRoleCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + SearchRoleCtrl = $controller('SearchRoleCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/roles/search_role/search_role.html b/client/app/designer/roles/search_role/search_role.html new file mode 100644 index 0000000..421c8e3 --- /dev/null +++ b/client/app/designer/roles/search_role/search_role.html @@ -0,0 +1,70 @@ + + diff --git a/client/app/designer/tasks/new_task/new_task.controller.js b/client/app/designer/tasks/new_task/new_task.controller.js new file mode 100644 index 0000000..153328c --- /dev/null +++ b/client/app/designer/tasks/new_task/new_task.controller.js @@ -0,0 +1,446 @@ +'use strict'; +const angular = require('angular'); + + + +/*@ngInject*/ +export function newTaskController($window, $scope, $sce, $uibModal, ansi2html, ansible, $uibModalInstance, tasksList, selectedTaskIndex, copyTask, files, selectedPlay, selectedRole, $filter, Projects) { + var selectedTask; + + /** + * Edit task - in case of edit task , selectedTaskIndex is not null. + * Set selectedTask to a copy of selected task to edit. + */ + if(selectedTaskIndex > -1 && tasksList){ + if(copyTask){ + selectedTask = angular.copy(tasksList[selectedTaskIndex]); + selectedTask.name = "Copy of " + selectedTask.name; + selectedTaskIndex = null; + }else{ + selectedTask = tasksList[selectedTaskIndex] + } + + } + + /** + * List of files for include purpose + */ + if(files){ + $scope.files = files; + } + + $scope.getModuleDescriptionLoading = false; + $scope.modulesLoading = false; + + $scope.modules = null; + $scope.singeLineModules = ["shell"]; + $scope.showHelp = false; + + $scope.newTask = {}; + $scope.title = "New Task"; + $scope.createTaskLoading = false; + + /** + * Get Ansible Modules + * If Edit Task, get module description for selected task + */ + $scope.getAnsibleModules = function(){ + $scope.modulesLoading = true; + ansible.getAnsibleModules(function(response){ + $scope.modules = response; + $scope.modulesLoading = false; + + if(selectedTask){ + $scope.title = "Edit Task"; + selectedTask = angular.copy(selectedTask); + $scope.newTask = selectedTask; + if(selectedTask.tags)$scope.newTask.tags = $scope.newTask.tags.join(','); + var module = $scope.getModuleFromTask(selectedTask); + $scope.getModuleDescription(module,true) + } + + }, function(response){ + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
").replace(/ /g," ")); + + }); + + }; + + + /** + * Get Module Description whenever a module is selected by the user + * @param module - Module Object + * @param override - Override variables in case of edit task + * @param refresh - Refresh module description from server. Don't display from cache + */ + $scope.getModuleDescription = function(module,override,refresh){ + + if(!module)return; + + var module_copy = angular.copy(module); + + $scope.getModuleDescriptionLoading = true; + var moduleName = module.name; + + if($scope.singeLineModules.indexOf(moduleName) > -1){ + module.singleLine = true; + } + + $scope.detailHelp = ""; + $scope.examples = ""; + module.variables = []; + + ansible.getAnsibleModuleDescription(moduleName, + function(response){ + $scope.showHelp = true; + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response).replace(/\n/g, "
").replace(/ /g," ")); + + $scope.detailHelp = response; + $scope.examples = response.substr(response.indexOf("#")); + //var re = /(^[-=] .*)/gm; + //var re = /(^[-=] .*)[^]*?(?:(\(Choices[^]+?\))?\s*(\[.*\])|(?=^[-=]|^EXAMPLES))/gm; + var re = /(^[-=] .*)([^]*?)(?:(\(Choices[^]+?\))?\s*(\[.*\])|(?=^[-=]|^EXAMPLES))/gm; + var m; + + while ((m = re.exec($scope.detailHelp.split("EXAMPLES")[0]+"EXAMPLES")) !== null) { + //while ((m = re.exec($scope.detailHelp.split("#")[0])) !== null) { + //while ((m = re.exec($scope.detailHelp)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + + var option_name = m[1]; + var description = m[2]; + var choices = m[3]; + var default_value = m[4]; + + + var breakup = option_name.split(" "); + var variable_name = breakup[1]; + var mandatory = breakup[0] == "="; + + var complex_value = {}; + + if(default_value) + default_value = default_value.replace(/\[Default: (.*)\]/,"$1"); + + if(default_value == 'None') + default_value = null + + var variable_value = default_value || ''; + + if(choices) + choices = choices.replace(/\s+/g,"").replace(/\n\s+/g,"").replace(/\(Choices:(.*)\)/,"$1").split(","); + + if(override && module_copy.variables){ + var matching_variable = module_copy.variables.filter(function(item){ + if(item.name == variable_name){ + return true + } + }); + if(matching_variable.length){ + variable_value = matching_variable[0].value; + if(typeof variable_value=='object'){ + complex_value = angular.copy(variable_value) + } + } + + } + + module.variables.push({name:variable_name,description:description,mandatory:mandatory,value:variable_value,complexValue:complex_value,choices:choices,default_value:default_value}); + $scope.getModuleDescriptionLoading = false; + } + }, function(response){ + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(response.data).replace(/\n/g, "
")); + console.log(ansi2html.toHtml(response.data)); + $scope.detailHelp = response.data; + $scope.getModuleDescriptionLoading = false; + },refresh) + }; + + /** + * Reload Module Description and variables. Ignore displaying from cache. + * Used when a custom module is updated and description and variables info need to be updated. + * @param module - module object + */ + $scope.reloadModuleDetails = function(module){ + + if(selectedTask){ + $scope.getModuleDescription(module,true,true) + }else{ + $scope.getModuleDescription(module,false,true) + } + }; + + + /** + * Identify module from a given task object. + * @param task - Single task object containing task properties + * @returns {{}} + */ + $scope.getModuleFromTask = function(task){ + var moduleObject = {}; + $scope.local_action = false; + var task_properties = null; + + var module = ansible.getModuleFromTask(task); + + if(module === 'include'){ + module = null; + task.tags = task.include.replace(/.*tags=(.*)/,"$1") + return; + }else if(module === 'local_action'){ + $scope.local_action = true; + module = task.local_action.module; + task_properties = task.local_action; + delete task_properties.module; + }else{ + task_properties = task[module]; + } + + angular.forEach($scope.modules, function(item,index) { + if(item.name == module){ + moduleObject = item; + $scope.newTask.module = item; + } + }); + + + if(!(moduleObject && moduleObject.name)){ + $scope.err_msg = "Unable to find module " + module + " in Ansible controller"; + return + } + + //moduleObject.name = module; + moduleObject.variables = []; + if(typeof task_properties == "string"){ + moduleObject.variables.push({'name':'free_form','value':task_properties}); + + var re = /\b(\w+)=\s*([^=]*\S)\b\s*(?=\w+=|$)/g; + var m; + + while ((m = re.exec(task_properties)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + var k=m[1]; + var v=m[2]; + moduleObject.variables.push({'name':k,'value':v}) + } + + }else if(typeof task_properties == "object"){ + + angular.forEach(task_properties,function(value,key){ + this.push({'name':key,'value':value,'complexValue':value}) + },moduleObject.variables) + + } + return moduleObject + + }; + + /** + * Create Task - Creates new task object and set task variables. + * Push new task object to tasksList + * Close new task modal + */ + $scope.createTask = function(){ + + if(!$scope.newTask.module && !$scope.newTask.include){ + $scope.err_msg = "Must select atleast one module or include statement"; + return + } + + $scope.createTaskLoading = true; + + if(!tasksList){ + tasksList = [] + } + + var taskObject = {name:$scope.newTask.name}; + + if($scope.newTask.include) + taskObject['include'] = $scope.newTask.include; + + if($scope.newTask.tags) + taskObject['tags'] = $scope.newTask.tags.split(','); + + if($scope.newTask.register) + taskObject['register'] = $scope.newTask.register; + + if($scope.newTask.async){ + taskObject['async'] = $scope.newTask.async; + if(!$scope.newTask.poll) + $scope.newTask.poll = 10; + taskObject['poll'] = $scope.newTask.poll; + } + + var variablesObject = null; + if($scope.newTask.module){ + if($scope.newTask.module.singleLine){ + variablesObject = ""; + //Add all mandatory variables first + angular.forEach($scope.newTask.module.variables.filter(function(item){ + return item.mandatory + }),function(item){ + if(item.name == 'free_form'){ + variablesObject += item.value; + }else if(item.value){ + variablesObject += " " + item.name + "=" + item.value; + } + }); + + //Add optional variables + angular.forEach($scope.newTask.module.variables.filter(function(item){ + return !item.mandatory + }),function(item){ + if(item.value != item.default_value){ + if(item.name == 'free_form'){ + variablesObject += item.value; + }else if(item.value){ + variablesObject += " " + item.name + "=" + item.value; + } + } + }); + + }else{ + variablesObject = {}; + angular.forEach($scope.newTask.module.variables,function(item){ + if((item.value || (item.isComplexVariable && item.complexValue)) && item.value != item.default_value){ + if(item.isComplexVariable){ + variablesObject[item.name] = item.complexValue; + }else{ + variablesObject[item.name] = item.value; + } + + } + }); + } + + taskObject[$scope.newTask.module.name] = variablesObject; + + if($scope.local_action){ + variablesObject.module = $scope.newTask.module.name; + taskObject['local_action'] = variablesObject; + } + } + + + + if(selectedTaskIndex != null){ + // If Edit Task + + tasksList[selectedTaskIndex] = taskObject + + }else{ + // If New Task + + tasksList.push(taskObject); + } + + $uibModalInstance.close(taskObject); + }; + + /** + * Close modal + */ + $scope.ok = function () { + $uibModalInstance.close($scope.newTask); + }; + + + /** + * Cancel modal + */ + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; + + /** + * Get host variables using Ansible Python API in the backend + */ + $scope.getHostVars = function(){ + + if(!(selectedPlay && selectedPlay.play && selectedPlay.play.hosts))return; + + ansible.getVars(Projects.selectedInventoryFileName,selectedPlay.play.hosts,function(response){ + console.log(response.data); + if(response.data.length) + $scope.hostvars = $filter('dictToKeyValueArray')(response.data[0]); + else $scope.err_msg = "Getting host variables - No variables returned" ; + + },function(error){ + console.log(error.data); + $scope.err_msg = "Getting host variables - " + error.data; + }) + }; + + if(selectedPlay) + $scope.getHostVars(); + + + $scope.getRoleVars = function(){ + + if(!(selectedRole && selectedRole.role))return; + + ansible.getRoleVars(selectedRole.role,function(response){ + console.log(response.data); + if(response.data) + $scope.hostvars = $filter('dictToKeyValueArray')(response.data); + else $scope.err_msg = "Getting host variables - No variables returned" ; + + },function(error){ + console.log(error.data); + $scope.err_msg = "Getting host variables - " + error.data; + }) + }; + + if(selectedRole) + $scope.getRoleVars(); + + + if(!$scope.modules){ + $scope.getAnsibleModules(); + } + + $scope.showComplexVariable = function(variable){ + variable.isComplexVariable = true; + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/modals/complex_var_modal/complexVariable.html', + controller: 'ComplexVarModalController', + size: 'sm', + backdrop: 'static', + keyboard: false, + closeByEscape: false, + closeByDocument: false, + resolve: { + path: function () { + return variable.name + }, + hostvars: function(){ + return $scope.hostvars + }, + members: function(){ + return variable.complexValue + } + } + }); + + modalInstance.result.then(function (selectedItem) { + variable.complexValue = selectedItem + }, function () { + + }); + + } +} + +export default angular.module('webAppApp.new_task', []) + .controller('NewTaskController', newTaskController) + .name; diff --git a/client/app/designer/tasks/new_task/new_task.controller.spec.js b/client/app/designer/tasks/new_task/new_task.controller.spec.js new file mode 100644 index 0000000..c77640c --- /dev/null +++ b/client/app/designer/tasks/new_task/new_task.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: NewTaskCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.new_task')); + + var NewTaskCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + NewTaskCtrl = $controller('NewTaskCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/designer/tasks/new_task/new_task.html b/client/app/designer/tasks/new_task/new_task.html new file mode 100644 index 0000000..c7e6d7d --- /dev/null +++ b/client/app/designer/tasks/new_task/new_task.html @@ -0,0 +1,134 @@ + + + + diff --git a/client/app/designer/tasks/tasks.css b/client/app/designer/tasks/tasks.css new file mode 100644 index 0000000..e69de29 diff --git a/client/app/designer/tasks/tasks.directive.js b/client/app/designer/tasks/tasks.directive.js new file mode 100644 index 0000000..0d1921e --- /dev/null +++ b/client/app/designer/tasks/tasks.directive.js @@ -0,0 +1,229 @@ +'use strict'; +const angular = require('angular'); + +export default angular.module('webAppApp.tasks', []) + .directive('tasks', function(ansible, $uibModal) { + return { + templateUrl: 'app/designer/tasks/tasks.html', + restrict: 'EA', + scope: { + tasksList: '=', + selectedPlay: '=', + savePlaybook: '&', + selectedRole: '=', + updatePlaybookFileContent: '&', + executeAnsiblePlayBook: '&', + files: '=' //List of files for include purpose + }, + link: function (scope, element, attrs) { + scope.getModuleFromTask = ansible.getModuleFromTask; + + scope.buttonStates = {loading:false,save:false,err_msg:false}; + + scope.tasksMetaData = []; + + scope.$watch('tasksList',function(){ + console.log('tasks list changed'); + scope.tasksMetaData = []; + + angular.forEach(scope.tasksList,function(task){ + var taskModule = ansible.getModuleFromTask(task); + var taskName = task.name; + + if(taskModule === 'include'){ + taskName = task[taskModule].replace(/(.*yml) .*/,"$1") + } + + scope.tasksMetaData.push({taskModule:taskModule,taskName:taskName,selected:false}) + }) + + },true); + + + /** + * Detect when the user selects tasks. + * Enable play button if tasks are selected and has tags assigned + * Enable delete button if tasks are selected + */ + scope.$watch('tasksMetaData',function(newValue,oldValue){ + scope.selectedTasksPlayButton = false; + scope.selectedTasksDeleteButton = false + + if(!(scope.tasksMetaData))return; + + var selectedTasks = scope.tasksMetaData.filter(item => item.selected); + var includeTasks = scope.tasksMetaData.filter(item => item.taskModule === 'include'); + var selectedTasksWithoutTags = []; + + /** + * Find selected tasks without any tags. + * If there are any play button will not be enabled + */ + angular.forEach(scope.tasksMetaData,function(item,index){ + scope.tasksListItem = scope.tasksList[index]; + if(!scope.tasksListItem.tags && item.selected){ + selectedTasksWithoutTags.push(scope.tasksListItem) + } + }); + + console.log("selectedTasksWithoutTags=") + console.log(selectedTasksWithoutTags) + + if(selectedTasks.length){ + //if(!includeTasks.length && !selectedTasksWithoutTags.length){ + if(!selectedTasksWithoutTags.length){ + scope.selectedTasksPlayButton = true + } + scope.selectedTasksDeleteButton = true + + }else{ + scope.selectedTasksPlayButton = false; + scope.selectedTasksDeleteButton = false + } + + },true); + + + //scope.moveUp = scope.moveUp(); + //scope.moveDown = scope.moveDown(); + scope.savePlaybook = scope.savePlaybook(); + scope.updatePlaybookFileContent = scope.updatePlaybookFileContent(); + scope.executeAnsiblePlayBook = scope.executeAnsiblePlayBook(); + + scope.showTaskModal = function(selectedTaskIndex, copyTask){ + var modalInstance = $uibModal.open({ + animation: true, + /*templateUrl: 'createTaskContent.html',*/ + templateUrl: 'app/designer/tasks/new_task/new_task.html', + controller: 'NewTaskController', + size: 'lg', + backdrop : 'static', + keyboard : false, + closeByEscape : false, + closeByDocument : false, + resolve: { + selectedProject: function () { + return scope.$parent.selectedProject; + }, + selectedPlay: function(){ + return scope.selectedPlay + }, + selectedRole: function(){ + return scope.selectedRole + }, + tasksList: function () { + return scope.tasksList; + }, + selectedTaskIndex: function(){ + return selectedTaskIndex + }, + copyTask : function(){ + return copyTask + }, + //List of files for include purpose + files: function(){ + return scope.files + } + } + }); + + modalInstance.result.then( + function (newTask) { + // if(!selectedTaskIndex) + // scope.tasksList.push(newTask); + scope.updatePlaybookFileContent(true); + //$scope.selectedPlay = {play: ""}; + }, function () { + + }); + + }; + + + scope.deleteTask = function(index){ + scope.tasksList.splice(index,1); + scope.updatePlaybookFileContent(true); + }; + + scope.deleteTasks = function(){ + + scope.tasksMetaData.filter(function(item, index){ + if(item.selected){ + scope.tasksList.splice(index,1); + } + }); + scope.updatePlaybookFileContent(true); + }; + + + scope.moveUp = function(list,index,buttonVariable){ + if(!scope.preChangeData) scope.preChangeData = angular.copy(list); + var temp = angular.copy(list[index]); + list[index] = list[index-1]; + list[index-1] = temp; + + scope.updatePlaybookFileContent(false); + + scope.buttonStates.save = true + + }; + + scope.cancelChange = function(buttonVariable){ + if(scope.preChangeData){ + //scope.tasksList = angular.copy(scope.preChangeData); + scope.selectedPlay.play.tasks = angular.copy(scope.preChangeData); + scope.preChangeData = null + + } + scope.updatePlaybookFileContent(false,null,scope.tasksList); + + scope.buttonStates.save = false; + }; + + scope.moveDown = function(list,index,buttonVariable){ + if(!scope.preChangeData) scope.preChangeData = angular.copy(list); + var temp = angular.copy(list[index]); + list[index] = list[index+1]; + list[index+1] = temp; + + scope.updatePlaybookFileContent(false); + scope.buttonStates.save = true; + + }; + + + scope.executeSelectedTasks = function(){ + + /*var selectedTasks = scope.tasksMetaData.map(function(item){return item.selected});*/ + var selectedTags = []; + var selectedTaskNames = []; + /*if(selectedTasks.length){ + selectedTags = selectedTasks.map(function(item){return item.tags}); + selectedTaskNames = selectedTasks.map(function(item){return item.name}) + }*/ + + angular.forEach(scope.tasksMetaData, function(item,index){ + if(item.selected){ + if(scope.tasksList[index].tags){ + // As tags is an array and each task can have multiple tags + var task_tags = scope.tasksList[index].tags + if(typeof task_tags == 'object') + task_tags = task_tags[0] //task_tags.join(',') + selectedTags.push(task_tags); + selectedTaskNames.push(scope.tasksList[index].name) + } + } + }); + + if(selectedTags.length){ + var play = scope.selectedPlay && scope.selectedPlay.play; + scope.executeAnsiblePlayBook(selectedTags,'Tasks',selectedTaskNames.join(","),play) + } + + + }; + + } + }; + }) + .name; diff --git a/client/app/designer/tasks/tasks.directive.spec.js b/client/app/designer/tasks/tasks.directive.spec.js new file mode 100644 index 0000000..1ee658f --- /dev/null +++ b/client/app/designer/tasks/tasks.directive.spec.js @@ -0,0 +1,20 @@ +'use strict'; + +describe('Directive: tasks', function() { + // load the directive's module and view + beforeEach(module('webAppApp.tasks')); + beforeEach(module('app/designer/tasks/tasks.html')); + + var element, scope; + + beforeEach(inject(function($rootScope) { + scope = $rootScope.$new(); + })); + + it('should make hidden element visible', inject(function($compile) { + element = angular.element(''); + element = $compile(element)(scope); + scope.$apply(); + expect(element.text()).to.equal('this is the tasks directive'); + })); +}); diff --git a/client/app/designer/tasks/tasks.html b/client/app/designer/tasks/tasks.html new file mode 100644 index 0000000..f05d002 --- /dev/null +++ b/client/app/designer/tasks/tasks.html @@ -0,0 +1,53 @@ +
+ + + + + + + + + + + + + + + + + +
SelectNameModuleActions
+ {{tasksMetaData[$index].taskName}}{{tasksMetaData[$index].taskModule}} +
+ + + + + + +
+
+
+ + + + + + diff --git a/client/app/directives/complexVar/complexVar.controller.js b/client/app/directives/complexVar/complexVar.controller.js new file mode 100644 index 0000000..707c2db --- /dev/null +++ b/client/app/directives/complexVar/complexVar.controller.js @@ -0,0 +1,60 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function complexVarController($scope,$filter) { + 'ngInject'; + var loadMembers = function(){ + $scope.membersCopy = angular.copy($scope.members); + //var membersArray = $filter('addDotInKey')($filter('dictToKeyValueArray')($scope.membersCopy)); + var membersArray = ($filter('dictToKeyValueArraySimple')($scope.membersCopy)); + + $scope.tabgroup = $scope.tabgroup || 0; + + if(membersArray.length) + $scope.membersCopy = membersArray; + else + $scope.membersCopy = [{key:"",value:""}]; + + $scope.path = $scope.path || ""; + + angular.forEach($scope.membersCopy,function(member){ + if(Object.prototype.toString.call(member.value) === '[object Object]'){ + member.type = 'object'; + }else if(Object.prototype.toString.call(member.value) === '[object Array]'){ + member.type = 'array'; + } + + }) + + }; + + loadMembers(); + + $scope.setMemberType = function(member,type){ + if(type === 'object'){ + member.value = {}; + member.type = 'object'; + }else if(type === 'array'){ + member.value = {}; + member.type = 'array'; + } + }; + + $scope.$on('membersUpdated',function(){ + console.log('On Members Updated'); + console.log($scope.members); + loadMembers(); + }); + + $scope.$watch('membersCopy',function(){ + if($scope.type === 'object') + $scope.members = $filter('removeDotInKey')($filter('keyValueArrayToDict')($scope.membersCopy)); + else if($scope.type === 'array') + $scope.members = $filter('keyValueArrayToArray')($scope.membersCopy); + },true) +} + +export default angular.module('webAppApp.complexVarCtrl', []) + .controller('ComplexVarController', complexVarController) + .name; diff --git a/client/app/directives/complexVar/complexVar.controller.spec.js b/client/app/directives/complexVar/complexVar.controller.spec.js new file mode 100644 index 0000000..9ace95d --- /dev/null +++ b/client/app/directives/complexVar/complexVar.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: ComplexVarCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.complexVar')); + + var ComplexVarCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + ComplexVarCtrl = $controller('ComplexVarCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/directives/complexVar/complexVar.css b/client/app/directives/complexVar/complexVar.css new file mode 100644 index 0000000..b8e523b --- /dev/null +++ b/client/app/directives/complexVar/complexVar.css @@ -0,0 +1,8 @@ +.complex-var-input { + line-height: 1; + padding-right: 5px; + padding-left: 5px; + padding-top: 2px; + padding-bottom: 2px; + height: 25px; +} diff --git a/client/app/directives/complexVar/complexVar.directive.js b/client/app/directives/complexVar/complexVar.directive.js new file mode 100644 index 0000000..116f3ea --- /dev/null +++ b/client/app/directives/complexVar/complexVar.directive.js @@ -0,0 +1,20 @@ +'use strict'; +const angular = require('angular'); + +export default angular.module('webAppApp.complexVar', []) + .directive('complexVar', function() { + return { + template: require('./complexVar.html'), + restrict: 'EA', + scope: { + members: '=', + path: '=', + type: '=', + inputWidth: '=', + tabgroup: '=?bind', + hostVars: '=' + }, + controller: 'ComplexVarController' + }; + }) + .name; diff --git a/client/app/directives/complexVar/complexVar.directive.spec.js b/client/app/directives/complexVar/complexVar.directive.spec.js new file mode 100644 index 0000000..58496c9 --- /dev/null +++ b/client/app/directives/complexVar/complexVar.directive.spec.js @@ -0,0 +1,20 @@ +'use strict'; + +describe('Directive: complexVar', function() { + // load the directive's module and view + beforeEach(module('webAppApp.complexVar')); + beforeEach(module('app/directives/complexVar/complexVar.html')); + + var element, scope; + + beforeEach(inject(function($rootScope) { + scope = $rootScope.$new(); + })); + + it('should make hidden element visible', inject(function($compile) { + element = angular.element(''); + element = $compile(element)(scope); + scope.$apply(); + expect(element.text()).to.equal('this is the complexVar directive'); + })); +}); diff --git a/client/app/directives/complexVar/complexVar.html b/client/app/directives/complexVar/complexVar.html new file mode 100644 index 0000000..65c33db --- /dev/null +++ b/client/app/directives/complexVar/complexVar.html @@ -0,0 +1,38 @@ +
+
+
+
Key
+
+
+
Value
+
+
+ +
+ +
+
+ +
+
+ + +
+
+
+ + + + +
+
+
+
+ + +
+ + +
+
+ diff --git a/client/app/filters/addDotInKey/addDotInKey.filter.js b/client/app/filters/addDotInKey/addDotInKey.filter.js new file mode 100644 index 0000000..331033f --- /dev/null +++ b/client/app/filters/addDotInKey/addDotInKey.filter.js @@ -0,0 +1,14 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function addDotInKeyFilter() { + return function(input) { + return JSON.parse(JSON.stringify(input).replace(/__dot__/g,'.')); + }; +} + + +export default angular.module('webAppApp.addDotInKey', []) + .filter('addDotInKey', addDotInKeyFilter) + .name; diff --git a/client/app/filters/addDotInKey/addDotInKey.filter.spec.js b/client/app/filters/addDotInKey/addDotInKey.filter.spec.js new file mode 100644 index 0000000..e9ba83d --- /dev/null +++ b/client/app/filters/addDotInKey/addDotInKey.filter.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Filter: addDotInKey', function() { + // load the filter's module + beforeEach(module('webAppApp.addDotInKey')); + + // initialize a new instance of the filter before each test + var addDotInKey; + beforeEach(inject(function($filter) { + addDotInKey = $filter('addDotInKey'); + })); + + it('should return the input prefixed with "addDotInKey filter:"', function() { + var text = 'angularjs'; + expect(addDotInKey(text)).to.equal('addDotInKey filter: ' + text); + }); +}); diff --git a/client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.js b/client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.js new file mode 100644 index 0000000..beef309 --- /dev/null +++ b/client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.js @@ -0,0 +1,29 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function dictToKeyValueArrayFilter() { + var convert = function (input,prefix) { + var result = []; + angular.forEach(input,function(value,key){ + key = key.replace(/\./g,"__dot__"); + if(prefix){ + key = prefix + '.' + key; + } + if(typeof value != 'object'){ + result.push({"key":key ,"value":value}) + }else{ + result = result.concat(convert(value,key)) + } + + }); + return result; + }; + + return convert; +} + + +export default angular.module('webAppApp.dictToKeyValueArray', []) + .filter('dictToKeyValueArray', dictToKeyValueArrayFilter) + .name; diff --git a/client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.spec.js b/client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.spec.js new file mode 100644 index 0000000..6026cc4 --- /dev/null +++ b/client/app/filters/dictToKeyValueArray/dictToKeyValueArray.filter.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Filter: dictToKeyValueArray', function() { + // load the filter's module + beforeEach(module('webAppApp.dictToKeyValueArray')); + + // initialize a new instance of the filter before each test + var dictToKeyValueArray; + beforeEach(inject(function($filter) { + dictToKeyValueArray = $filter('dictToKeyValueArray'); + })); + + it('should return the input prefixed with "dictToKeyValueArray filter:"', function() { + var text = 'angularjs'; + expect(dictToKeyValueArray(text)).to.equal('dictToKeyValueArray filter: ' + text); + }); +}); diff --git a/client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.js b/client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.js new file mode 100644 index 0000000..bdd5e86 --- /dev/null +++ b/client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.js @@ -0,0 +1,20 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function dictToKeyValueArraySimpleFilter() { + var convert = function (input,prefix) { + var result = []; + angular.forEach(input,function(value,key){ + result.push({"key":key ,"value":value}) + }); + return result; + }; + + return convert; +} + + +export default angular.module('webAppApp.dictToKeyValueArraySimple', []) + .filter('dictToKeyValueArraySimple', dictToKeyValueArraySimpleFilter) + .name; diff --git a/client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.spec.js b/client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.spec.js new file mode 100644 index 0000000..b18b7c2 --- /dev/null +++ b/client/app/filters/dictToKeyValueArraySimple/dictToKeyValueArraySimple.filter.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Filter: dictToKeyValueArraySimple', function() { + // load the filter's module + beforeEach(module('webAppApp.dictToKeyValueArraySimple')); + + // initialize a new instance of the filter before each test + var dictToKeyValueArraySimple; + beforeEach(inject(function($filter) { + dictToKeyValueArraySimple = $filter('dictToKeyValueArraySimple'); + })); + + it('should return the input prefixed with "dictToKeyValueArraySimple filter:"', function() { + var text = 'angularjs'; + expect(dictToKeyValueArraySimple(text)).to.equal('dictToKeyValueArraySimple filter: ' + text); + }); +}); diff --git a/client/app/filters/json2yaml/json2yaml.filter.js b/client/app/filters/json2yaml/json2yaml.filter.js new file mode 100644 index 0000000..3742e79 --- /dev/null +++ b/client/app/filters/json2yaml/json2yaml.filter.js @@ -0,0 +1,118 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function json2yamlFilter() { + + + /* + * TODO, lots of concatenation (slow in js) + */ + var spacing = " "; + + function getType(obj) { + var type = typeof obj; + if (obj instanceof Array) { + return 'array'; + } else if (type == 'string') { + return 'string'; + } else if (type == 'boolean') { + return 'boolean'; + } else if (type == 'number') { + return 'number'; + } else if (type == 'undefined' || obj === null) { + return 'null'; + } else { + return 'hash'; + } + } + + function convert(obj, ret) { + var type = getType(obj); + + switch(type) { + case 'array': + convertArray(obj, ret); + break; + case 'hash': + convertHash(obj, ret); + break; + case 'string': + convertString(obj, ret); + break; + case 'null': + ret.push('null'); + break; + case 'number': + ret.push(obj.toString()); + break; + case 'boolean': + ret.push(obj ? 'true' : 'false'); + break; + } + } + + function convertArray(obj, ret) { + for (var i=0; i { + this.awesomeThings = response.data; + }); + } + + addThing() { + if(this.newThing) { + this.$http.post('/api/things', { + name: this.newThing + }); + this.newThing = ''; + } + } + + deleteThing(thing) { + this.$http.delete(`/api/things/${thing._id}`); + } +} + +export default angular.module('app2App.main', [uiRouter]) + .config(routing) + .component('main', { + template: require('./main.html'), + controller: MainController + }) + .name; diff --git a/client/app/main/main.component.spec.js b/client/app/main/main.component.spec.js new file mode 100644 index 0000000..34f4962 --- /dev/null +++ b/client/app/main/main.component.spec.js @@ -0,0 +1,37 @@ +'use strict'; + +import main from './main.component'; +import { + MainController +} from './main.component'; + +describe('Component: MainComponent', function() { + beforeEach(angular.mock.module(main)); + beforeEach(angular.mock.module('stateMock')); + + var scope; + var mainComponent; + var state; + var $httpBackend; + + // Initialize the controller and a mock scope + beforeEach(inject(function(_$httpBackend_, $http, $componentController, $rootScope, $state) { + $httpBackend = _$httpBackend_; + $httpBackend.expectGET('/api/things') + .respond(['HTML5 Boilerplate', 'AngularJS', 'Karma', 'Express']); + + scope = $rootScope.$new(); + state = $state; + mainComponent = $componentController('main', { + $http, + $scope: scope + }); + })); + + it('should attach a list of things to the controller', function() { + mainComponent.$onInit(); + $httpBackend.flush(); + expect(mainComponent.awesomeThings.length) + .to.equal(4); + }); +}); diff --git a/client/app/main/main.css b/client/app/main/main.css new file mode 100644 index 0000000..959d765 --- /dev/null +++ b/client/app/main/main.css @@ -0,0 +1,27 @@ +.thing-form { + margin: 20px 0; +} + +#banner { + border-bottom: none; + margin-top: -20px; +} + +#banner h1 { + font-size: 60px; + line-height: 1; + letter-spacing: -1px; +} + +.hero-unit { + position: relative; + padding: 30px 15px; + color: #F5F5F5; + text-align: center; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); + background: #2d363a; +} + +.navbar-text { + margin-left: 15px; +} diff --git a/client/app/main/main.html b/client/app/main/main.html new file mode 100644 index 0000000..cd4fb40 --- /dev/null +++ b/client/app/main/main.html @@ -0,0 +1,82 @@ + + +
+
+
+
+ +
+
+

Generate Ansible Playbooks

+
    +
  • Generate Ansible playbooks using UI
  • +
  • Create playbooks, plays and tasks in few clicks
  • +
  • Lists all available ansible modules
  • +
  • Provides description and examples of modules
  • +
  • Provides an interface to input parameters for each module
  • +
+
+
+
+
+

Import input

+

Import inputs into ansible inventory file format.

+
+
+ +
+ +
+
+
+ +
+
+

Play!

+
    +
  • Run your playbooks and view results
  • +
  • Run playbooks at a play or task level
  • +
+
+ +
+
+ +
+ +
+
+
+
+

About us

+
+
+
+

Mumshad Mannambeth

+ +
+
+
+
This is a proof-of-concept implementation. This is only to be used for testing purpose and NOT to be used in production.
+
+
+
+
+ +
+
+
+

Also check out

+
+ + +
+
+
+
diff --git a/client/app/main/main.routes.js b/client/app/main/main.routes.js new file mode 100644 index 0000000..280e75b --- /dev/null +++ b/client/app/main/main.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function routes($stateProvider) { + 'ngInject'; + + $stateProvider.state('main', { + url: '/', + template: '
' + }); +} diff --git a/client/app/modals/complex_var_modal/complexVariable.html b/client/app/modals/complex_var_modal/complexVariable.html new file mode 100644 index 0000000..c940c81 --- /dev/null +++ b/client/app/modals/complex_var_modal/complexVariable.html @@ -0,0 +1,19 @@ + + diff --git a/client/app/modals/complex_var_modal/complex_var_modal.controller.js b/client/app/modals/complex_var_modal/complex_var_modal.controller.js new file mode 100644 index 0000000..f58d35c --- /dev/null +++ b/client/app/modals/complex_var_modal/complex_var_modal.controller.js @@ -0,0 +1,21 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function complexVarModalController($scope,$uibModalInstance,hostvars,members) { + + $scope.members = members || {}; + $scope.hostvars = hostvars; + + $scope.ok = function () { + $uibModalInstance.close($scope.members); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +} + +export default angular.module('webAppApp.complex_var_modal', []) + .controller('ComplexVarModalController', complexVarModalController) + .name; diff --git a/client/app/modals/complex_var_modal/complex_var_modal.controller.spec.js b/client/app/modals/complex_var_modal/complex_var_modal.controller.spec.js new file mode 100644 index 0000000..4839e0b --- /dev/null +++ b/client/app/modals/complex_var_modal/complex_var_modal.controller.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Controller: ComplexVarModalCtrl', function() { + // load the controller's module + beforeEach(module('webAppApp.complex_var_modal')); + + var ComplexVarModalCtrl; + + // Initialize the controller and a mock scope + beforeEach(inject(function($controller) { + ComplexVarModalCtrl = $controller('ComplexVarModalCtrl', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/project/project.component.js b/client/app/project/project.component.js new file mode 100644 index 0000000..e00d25e --- /dev/null +++ b/client/app/project/project.component.js @@ -0,0 +1,121 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './project.routes'; + +export class ProjectComponent { + /*@ngInject*/ + constructor($scope, Projects) { + 'ngInject'; + var default_project_folder = '/opt/ehc-ansible-projects/'; + + $scope.blankProject = { + name: '', + ansibleEngine: { + ansibleHost: '', + ansibleHostUser: '', + ansibleHostPassword: '', + projectFolder: '', + customModules: '' + + } + }; + + $scope.msg = ""; + $scope.msg_status = ""; + + $scope.newProject = $scope.blankProject; + + $scope.editProjectFlag = false; + + $scope.saveButtonIcon = "fa-save"; + + $scope.showProjectForm = function(){ + $scope.showCreateProject = true; + $scope.editProjectFlag = false; + $scope.newProject = $scope.blankProject; + $scope.msg = ""; + $scope.msg_status = ""; + }; + + + $scope.hideProjectForm = function(){ + $scope.showCreateProject = false; + $scope.editProjectFlag = false; + $scope.newProject = $scope.blankProject; + $scope.msg = ""; + $scope.msg_status = ""; + }; + + $scope.getProjects = function(){ + console.log("Getting Projects"); + $scope.projects = Projects.resource.query(function(){ + console.log($scope.projects); + }) + + }; + + $scope.getProjects(); + + $scope.deleteProject = function(project){ + project.$remove(function(){ + $scope.getProjects(); + }) + }; + + $scope.editProject = function(project){ + $scope.showCreateProject = true; + $scope.editProjectFlag = true; + $scope.newProject = project; + }; + + $scope.createProject = function(){ + $scope.newProject.creationTime = new Date(); + //$scope.newProject.ansibleEngine.projectFolder = default_project_folder + '/' + $scope.newProject.name + //$scope.newProject.ansibleEngine.customModules = $scope.newProject.ansibleEngine.projectFolder + '/library' + + var projectSavedCallback = function(){ + $scope.showCreateProject = false; + $scope.msg = "Project Saved Successfully"; + $scope.msg_status = "success"; + $scope.getProjects(); + $scope.saveButtonIcon = 'fa-save'; + }; + + var projectSaveFailedCallback = function(errResponse){ + $scope.msg = errResponse.data; + $scope.msg_status = "danger"; + $scope.saveButtonIcon = 'fa-save'; + }; + + $scope.saveButtonIcon = 'fa-spinner fa-spin'; + + if($scope.editProjectFlag){ + $scope.editProjectFlag = false; + $scope.newProject.$update(projectSavedCallback,projectSaveFailedCallback); + }else{ + Projects.resource.save($scope.newProject,projectSavedCallback,projectSaveFailedCallback); + } + + + }; + + $scope.$watch('newProject.name', function(newValue, oldValue){ + console.log("Changed"); + $scope.newProject.ansibleEngine.projectFolder = '/opt/ansible-projects/' + newValue; + $scope.newProject.ansibleEngine.customModules = '/opt/ansible-projects/' + newValue + '/library'; + }); + + } +} + +export default angular.module('webAppApp.project', [uiRouter]) + .config(routes) + .component('project', { + template: require('./project.html'), + controller: ProjectComponent, + controllerAs: 'projectCtrl' + }) + .name; diff --git a/client/app/project/project.component.spec.js b/client/app/project/project.component.spec.js new file mode 100644 index 0000000..12db8ac --- /dev/null +++ b/client/app/project/project.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: ProjectComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.project')); + + var ProjectComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + ProjectComponent = $componentController('project', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/project/project.css b/client/app/project/project.css new file mode 100644 index 0000000..73b4fa8 --- /dev/null +++ b/client/app/project/project.css @@ -0,0 +1,5 @@ +.hint { + /* Copy styles from ng-messages */ + font-size: 12px; + /* Set our own color */ + color: grey; } diff --git a/client/app/project/project.html b/client/app/project/project.html new file mode 100644 index 0000000..d35d6ef --- /dev/null +++ b/client/app/project/project.html @@ -0,0 +1,80 @@ +
+
+
+ + + +
+

+ + +

+ +

+ + +

Ansible Controller system - A linux system with Ansible Installed on it. Required if you want to execute Ansible playbooks. You could skip this and still generate playbooks but not test them.
+

+ +

+ + +

+ +

+ + +

+ +

+ + +

A directory path to store files of this project
+

+ +

+ + +

A directory path to store custom modules for this project
+

+ + + + +
+ +
{{msg}}
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
NameAnsible HostAnsible VersionTimeActions
{{project.name}}{{project.ansibleEngine.ansibleHost}}{{project.ansibleVersion}}{{project.creationTime | date : 'medium'}} +
+ + +
+
+
+
diff --git a/client/app/project/project.routes.js b/client/app/project/project.routes.js new file mode 100644 index 0000000..9702d4f --- /dev/null +++ b/client/app/project/project.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('project', { + url: '/project', + template: '' + }); +} diff --git a/client/app/providers/ansi2html/ansi2html.service.js b/client/app/providers/ansi2html/ansi2html.service.js new file mode 100644 index 0000000..4a3df36 --- /dev/null +++ b/client/app/providers/ansi2html/ansi2html.service.js @@ -0,0 +1,32 @@ +'use strict'; +const angular = require('angular'); + +var ansi_to_html = require('ansi-to-html'); + +/*@ngInject*/ +export function ansi2htmlProvider() { + // Private variables + var salutation = 'Hello'; + + // Private constructor + function Greeter() { + this.greet = function() { + return salutation; + }; + } + + // Public API for configuration + this.setSalutation = function(s) { + salutation = s; + }; + + // Method for instantiating + this.$get = ['$window', function ($window) { + // configure JSONEditor using provider's configuration + return new ansi_to_html(); + }]; +} + +export default angular.module('webAppApp.ansi2html', []) + .provider('ansi2html', ansi2htmlProvider) + .name; diff --git a/client/app/providers/ansi2html/ansi2html.service.spec.js b/client/app/providers/ansi2html/ansi2html.service.spec.js new file mode 100644 index 0000000..2025d77 --- /dev/null +++ b/client/app/providers/ansi2html/ansi2html.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: ansi2html', function() { + // load the service's module + beforeEach(module('webAppApp.ansi2html')); + + // instantiate service + var ansi2html; + beforeEach(inject(function(_ansi2html_) { + ansi2html = _ansi2html_; + })); + + it('should do something', function() { + expect(!!ansi2html).to.be.true; + }); +}); diff --git a/client/app/providers/yaml/yaml.service.js b/client/app/providers/yaml/yaml.service.js new file mode 100644 index 0000000..c363515 --- /dev/null +++ b/client/app/providers/yaml/yaml.service.js @@ -0,0 +1,30 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function yamlProvider() { + // Private variables + var salutation = 'Hello'; + + // Private constructor + function Greeter() { + this.greet = function() { + return salutation; + }; + } + + // Public API for configuration + this.setSalutation = function(s) { + salutation = s; + }; + + // Method for instantiating + this.$get = ['$window', function ($window) { + // configure JSONEditor using provider's configuration + return $window.YAML; + }]; +} + +export default angular.module('webAppApp.yaml', []) + .provider('YAML', yamlProvider) + .name; diff --git a/client/app/providers/yaml/yaml.service.spec.js b/client/app/providers/yaml/yaml.service.spec.js new file mode 100644 index 0000000..0ce3fc1 --- /dev/null +++ b/client/app/providers/yaml/yaml.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: yaml', function() { + // load the service's module + beforeEach(module('webAppApp.yaml')); + + // instantiate service + var yaml; + beforeEach(inject(function(_yaml_) { + yaml = _yaml_; + })); + + it('should do something', function() { + expect(!!yaml).to.be.true; + }); +}); diff --git a/client/app/runs/runs.component.js b/client/app/runs/runs.component.js new file mode 100644 index 0000000..ac33ccb --- /dev/null +++ b/client/app/runs/runs.component.js @@ -0,0 +1,124 @@ +'use strict'; +const angular = require('angular'); + +const uiRouter = require('angular-ui-router'); + +import routes from './runs.routes'; + +export class RunsComponent { + /*@ngInject*/ + constructor(ansible,$scope,$sce,$uibModal,ansi2html,Projects) { + 'ngInject'; + /** + * Get list of projects from server + */ + $scope.getProjects = function(){ + $scope.projects = Projects.resource.query(function(){ + if($scope.projects.length){ + $scope.selectedProjectID = localStorage.selectedProjectID || $scope.projects[0]._id; + $scope.projectSelected($scope.selectedProjectID) + } + + }) + }; + + $scope.getProjects(); + + /** + * On ProjectSelected - set selectedProjectID in cache + * @param projectID + */ + $scope.projectSelected = function(projectID){ + localStorage.selectedProjectID = projectID; + + $scope.selectedProject = Projects.resource.get({id: projectID},function(){ + Projects.selectedProject = $scope.selectedProject; + $scope.$broadcast('projectLoaded'); + }) + + }; + + $scope.showLogs = function(runData){ + var modalInstance = $uibModal.open({ + animation: false, + templateUrl: 'app/designer/execution/executeModal.html', + controller: 'ExecutionController', + size: 'lg', + backdrop : 'static', + keyboard : false, + closeByEscape : false, + closeByDocument : false, + resolve: { + inventory_file_contents: function () { + return null; + }, + yaml: function () { + return null; + }, + tags: function(){ + return null + }, + selectedProject: function(){ + return null + }, + selectedPlaybook: function(){ + return null + }, + selectedPlay: function(){ + return null + }, + executionType: function(){ + return null + }, + executionName: function(){ + return null + }, + readOnly: function(){ + return true + }, + runData: function(){ + return runData + }, + projectFolder: function () { + return null + }, + roleName: function () { + return null + } + } + }); + } + + $scope.executeAnsiblePlayBook = function(tags,executionType,executionName,selectedPlay){ + + }; + + $scope.getLogs = function(){ + ansible.getLogs($scope.executionData,function(successResponse) { + $scope.result = $sce.trustAsHtml(ansi2html.toHtml(successResponse.data).replace(/\n/g, "
")); + + if(successResponse.data.indexOf('SCRIPT_FINISHED') > -1){ + $scope.refreshLog = false; + } + + }); + }; + + ansible.query( + function(response){ + $scope.runs = response.data + }, + function(response){ + + }) + } +} + +export default angular.module('webAppApp.runs', [uiRouter]) + .config(routes) + .component('runs', { + template: require('./runs.html'), + controller: RunsComponent, + controllerAs: 'runsCtrl' + }) + .name; diff --git a/client/app/runs/runs.component.spec.js b/client/app/runs/runs.component.spec.js new file mode 100644 index 0000000..4d6fe48 --- /dev/null +++ b/client/app/runs/runs.component.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +describe('Component: RunsComponent', function() { + // load the controller's module + beforeEach(module('webAppApp.runs')); + + var RunsComponent; + + // Initialize the controller and a mock scope + beforeEach(inject(function($componentController) { + RunsComponent = $componentController('runs', {}); + })); + + it('should ...', function() { + expect(1).to.equal(1); + }); +}); diff --git a/client/app/runs/runs.css b/client/app/runs/runs.css new file mode 100644 index 0000000..e69de29 diff --git a/client/app/runs/runs.html b/client/app/runs/runs.html new file mode 100644 index 0000000..ad39cd0 --- /dev/null +++ b/client/app/runs/runs.html @@ -0,0 +1,44 @@ + +
+

+ + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HostsTagsVerboseCheck ModeSelected PlaybookTypeNameTimeLogs
{{run.host}}{{run.tags.join(', ')}}{{run.verbose}}{{run.check_mode}}{{run.selectedPlaybook}}{{run.executionType}}{{run.executionName}}{{run.executionTime | date : 'medium'}}
+
+
diff --git a/client/app/runs/runs.routes.js b/client/app/runs/runs.routes.js new file mode 100644 index 0000000..397ccb9 --- /dev/null +++ b/client/app/runs/runs.routes.js @@ -0,0 +1,10 @@ +'use strict'; + +export default function($stateProvider) { + 'ngInject'; + $stateProvider + .state('runs', { + url: '/runs', + template: '' + }); +} diff --git a/client/app/services/ansible/ansible.service.js b/client/app/services/ansible/ansible.service.js new file mode 100644 index 0000000..cfaa237 --- /dev/null +++ b/client/app/services/ansible/ansible.service.js @@ -0,0 +1,302 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function ansibleService($http,YAML,Projects) { + // AngularJS will instantiate a singleton by calling "new" on this function + // AngularJS will instantiate a singleton by calling "new" on this function + + var AnsibleService = this; + + var uri = '/api/ansible/'; + var emptyPlaybookContent = '---\n# Playbook File -'; + var defaultTaskProperties = ["variables","tags","name","notify","with_items","first_available_file","only_if","user","sudo","connection","when","register","ignore_errors","selected","changed_when","delegate_to","vars","poll","async","args","with_together"]; + + + AnsibleService.modules = {}; + + this.getAnsibleModules = function(successCallback,errorCallback,parent,refresh){ + if(!refresh && AnsibleService.modules[Projects.selectedProject.ansibleEngine.ansibleHost]) + return successCallback(AnsibleService.modules[Projects.selectedProject.ansibleEngine.ansibleHost]); + + try{ + if(!refresh && JSON.parse(localStorage['modules_' + Projects.selectedProject.ansibleEngine.ansibleHost])) + return successCallback(JSON.parse(localStorage['modules_' + Projects.selectedProject.ansibleEngine.ansibleHost])); + }catch(e){ + //console.error(e) + } + + $http.post(uri + 'modules',{ansibleEngine:Projects.selectedProject.ansibleEngine}).then(function(response){ + var result = response.data.split('\n'); + AnsibleService.modules[Projects.selectedProject.ansibleEngine.ansibleHost] = result.map(function(item){ + return {"name" : item.split(" ")[0], "description": item.split(/ (.+)?/)[1]} + }); + + localStorage['modules_' + Projects.selectedProject.ansibleEngine.ansibleHost] = JSON.stringify(AnsibleService.modules[Projects.selectedProject.ansibleEngine.ansibleHost]); + + successCallback(AnsibleService.modules[Projects.selectedProject.ansibleEngine.ansibleHost]) + + },errorCallback) + }; + + + this.getAnsibleModuleDescription = function(moduleName, successCallback,errorCallback,refresh){ + + try{ + if(!refresh && JSON.parse(localStorage['module_description_' + Projects.selectedProject.ansibleEngine.ansibleHost + '_' + moduleName])) + return successCallback(JSON.parse(localStorage['module_description_' + Projects.selectedProject.ansibleEngine.ansibleHost + '_' + moduleName])); + }catch(e){ + + } + + var command = 'ansible-doc ' + moduleName; + + if(Projects.selectedProject.ansibleEngine.customModules){ + command = 'export ANSIBLE_LIBRARY="' + Projects.selectedProject.ansibleEngine.customModules + '"; ' + command; + } + + $http.post(uri + 'command',{ansibleEngine:Projects.selectedProject.ansibleEngine,command:command}) + .then(function(response){ + + localStorage['module_description_' + Projects.selectedProject.ansibleEngine.ansibleHost + '_' + moduleName] = JSON.stringify(response.data); + successCallback(response.data) + + },errorCallback) + + }; + + this.executeAnsiblePlayBook = function(body,successCallback,errorCallback,parent){ + $http.post(uri + 'execute',body).then(successCallback,errorCallback) + }; + + this.executeCommand = function(command, successCallback,errorCallback){ + $http.post(uri + 'command',{command:command,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.getLogs = function(executionData,successCallback,errorCallback){ + $http.get(uri+'logs/'+executionData._id).then(successCallback,errorCallback); + }; + + this.query = function(successCallback,errorCallback){ + $http.get(uri).then(successCallback,errorCallback); + }; + + this.getModuleFromTask = function(task){ + var module = null; + angular.forEach(JSON.parse(angular.toJson(task)), function(value, key) { + if(defaultTaskProperties.indexOf(key) < 0){ + module = key + } + }); + + if(module === 'include' && !task.tags && task.include.indexOf('tags') > -1){ + task.tags = task.include.replace(/.*tags=(.*)/,"$1") + } + + return module; + + }; + // -------------------------- PROJECT ------------------------- + this.getProjectFiles = function(successCallback,errorCallback){ + $http.post(uri + 'project/files',{ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + + // -------------------------- PLAYBOOK ------------------------- + + this.getPlaybookList = function(successCallback,errorCallback){ + $http.post(uri + 'playbook/list',{ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.deletePlaybook = function(playbookName,successCallback,errorCallback){ + + $http.post(uri + 'playbook/delete',{ansibleEngine:Projects.selectedProject.ansibleEngine,playbookName:playbookName}).then(successCallback,errorCallback) + + }; + + this.createPlaybook = function(playbookName,playbookFileContents,successCallback,errorCallback){ + var playbookContent = playbookFileContents || (emptyPlaybookContent + playbookName); + $http.post(uri + 'playbook/create',{ansibleEngine:Projects.selectedProject.ansibleEngine,playbookName:playbookName,playbookFileContents:playbookContent}).then(successCallback,errorCallback) + }; + + this.readPlaybook = function(playbookName,successCallback,errorCallback){ + $http.post(uri + 'playbook/get',{ansibleEngine:Projects.selectedProject.ansibleEngine,playbookName:playbookName}).then(successCallback,errorCallback) + }; + + this.readPlaybookData = function(playbookData){ + return YAML.parse(playbookData) + }; + + // -------------------------- ROLES ------------------------- + + this.getRoleList = function(successCallback,errorCallback){ + $http.post(uri + 'roles/list',{ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.searchRolesGalaxy = function(searchText,successCallback,errorCallback){ + $http.post(uri + 'roles/search/galaxy',{searchText:searchText,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.searchRolesGithub = function(searchText,successCallback,errorCallback){ + $http.post(uri + 'roles/search/github',{searchText:searchText,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.createRole = function(roleName,successCallback,errorCallback,selectedRoleName){ + $http.post(uri + 'roles/create',{roleName:roleName,selectedRoleName:selectedRoleName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.importRole = function(roleType,roleNameUri,successCallback,errorCallback){ + $http.post(uri + 'roles/import',{roleType:roleType,roleNameUri:roleNameUri,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.deleteRole = function(roleName,successCallback,errorCallback){ + $http.post(uri + 'roles/delete',{roleName:roleName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.getRoleFiles = function(roleName,successCallback,errorCallback){ + $http.post(uri + 'roles/files',{roleName:roleName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + // -------------------------- FILES ------------------------- + + this.createFile = function(fileAbsolutePath,successCallback,errorCallback,selectedFileName){ + $http.post(uri + 'files/create',{fileAbsolutePath:fileAbsolutePath,selectedFileName:selectedFileName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.updateFile = function(fileAbsolutePath,fileContents,successCallback,errorCallback,selectedFileName){ + $http.post(uri + 'files/update',{fileAbsolutePath:fileAbsolutePath,fileContents:fileContents,selectedFileName:selectedFileName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.deleteFile = function(fileAbsolutePath,successCallback,errorCallback,selectedFileName){ + $http.post(uri + 'files/delete',{fileAbsolutePath:fileAbsolutePath,selectedFileName:selectedFileName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + // -------------------------- INVENTORY ------------------------- + + this.getInventoryList = function(successCallback,errorCallback, projectFolder){ + // Override project folder for other cases, such as roles + var ansibleEngine = Projects.selectedProject.ansibleEngine; + if(projectFolder){ + ansibleEngine = angular.copy(Projects.selectedProject.ansibleEngine); + ansibleEngine.projectFolder = projectFolder + } + $http.post(uri + 'inventory/list',{ansibleEngine:ansibleEngine}).then(successCallback,errorCallback) + }; + + this.readInventory = function(inventoryName,successCallback,errorCallback){ + $http.post(uri + 'inventory/get',{inventoryName:inventoryName,ansibleEngine:Projects.selectedProject.ansibleEngine}).then(successCallback,errorCallback) + }; + + this.deleteInventory = function(inventoryName,successCallback,errorCallback){ + $http.post(uri + 'inventory/delete',{ansibleEngine:Projects.selectedProject.ansibleEngine,inventoryName:inventoryName}).then(successCallback,errorCallback) + }; + + this.createInventory = function(inventoryName,inventoryFileContents,successCallback,errorCallback){ + $http.post(uri + 'inventory/create',{ansibleEngine:Projects.selectedProject.ansibleEngine,inventoryName:inventoryName,inventoryFileContents:inventoryFileContents}).then(successCallback,errorCallback) + }; + + // -------------------------- Variable Files ------------------------- + + this.getVars = function(inventoryFileName, hostName, successCallback,errorCallback){ + $http.post(uri + 'vars/hosts/get',{ansibleEngine:Projects.selectedProject.ansibleEngine,hostName:hostName,inventoryFileName:inventoryFileName}).then(successCallback,errorCallback) + }; + + this.getRoleVars = function(roleName, successCallback,errorCallback){ + $http.post(uri + 'vars/roles/get',{ansibleEngine:Projects.selectedProject.ansibleEngine,roleName:roleName}).then(successCallback,errorCallback) + }; + + this.updateGroupVarsFile = function(groupName,groupVarsContents,successCallback,errorCallback){ + $http.post(uri + 'vars_file/groups/update',{ansibleEngine:Projects.selectedProject.ansibleEngine,groupName:groupName,groupVarsContents:groupVarsContents}).then(successCallback,errorCallback) + }; + + this.getGroupVarsFile = function(groupName,successCallback,errorCallback){ + $http.post(uri + 'vars_file/groups/get',{ansibleEngine:Projects.selectedProject.ansibleEngine,groupName:groupName}).then(successCallback,errorCallback) + }; + + this.updateHostVarsFile = function(hostName,hostVarsContents,successCallback,errorCallback){ + $http.post(uri + 'vars_file/hosts/update',{ansibleEngine:Projects.selectedProject.ansibleEngine,hostName:hostName,hostVarsContents:hostVarsContents}).then(successCallback,errorCallback) + }; + + this.getHostVarsFile = function(hostName,successCallback,errorCallback){ + $http.post(uri + 'vars_file/hosts/get',{ansibleEngine:Projects.selectedProject.ansibleEngine,hostName:hostName}).then(successCallback,errorCallback) + }; + + // ------------------- TAGS LIST -------------------------- + + this.getTagList = function(selectedPlaybook,inventory_file_name,ansibleEngine,successCallback,errorCallback){ + $http.post(uri + 'tags/list',{ansibleEngine:ansibleEngine,inventory_file_name:inventory_file_name,selectedPlaybook:selectedPlaybook}).then(successCallback,errorCallback) + }; + + // ------------- SOME HELPER FUNCTIONS -------------- + + this.parseINIString = function(data){ + var regex = { + section: /^\s*\[\s*([^\]]*)\s*\]\s*$/, + param: /^\s*([\w\.\-\_]+).*$/, + comment: /^\s*;.*$/ + }; + + var hosts = []; + var groups = []; + var lines = data.split(/\r\n|\r|\n/); + var group = null; + + group = {'name':'Un grouped', 'members': [], 'type': 'default'}; + groups.push(group); + + // groups.push({'name':'All Hosts', 'members': hosts, 'type': 'default'}); + + lines.forEach(function(line){ + if(regex.comment.test(line)){ + return; + }else if(regex.param.test(line)){ + var match = line.match(regex.param); + var host = match[1]; + if(hosts.indexOf(host) < 0){ + hosts.push(host); + } + + if(group && group.members.indexOf(host) < 0){ + group.members.push(host); + } + + }else if(regex.section.test(line)){ + var match = line.match(regex.section); + group = {'name':match[1], 'members': [], 'type': 'userdefined'}; + groups.push(group); + } + }); + return {'hosts':hosts,'groups':groups}; + }; + + + this.jsonToAnsibleInventoryIni = function(inventoryData){ + + var name = inventoryData.name; + var hosts = inventoryData.hosts; + var groups = inventoryData.groups; + + var result_lines = ['# Inventory File - ' + name, '']; + + angular.forEach(groups,function(group){ + if(group.name == 'All Hosts')return; + + if(group.name !== 'Un grouped'){ + result_lines.push(''); + result_lines.push('[' + group.name + ']'); + } + + angular.forEach(group.members,function(member){ + result_lines.push(member); + }) + + }); + + return result_lines.join('\n') + + } +} + +export default angular.module('webAppApp.ansible', []) + .service('ansible', ansibleService) + .name; diff --git a/client/app/services/ansible/ansible.service.spec.js b/client/app/services/ansible/ansible.service.spec.js new file mode 100644 index 0000000..331dd39 --- /dev/null +++ b/client/app/services/ansible/ansible.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: ansible', function() { + // load the service's module + beforeEach(module('webAppApp.ansible')); + + // instantiate service + var ansible; + beforeEach(inject(function(_ansible_) { + ansible = _ansible_; + })); + + it('should do something', function() { + expect(!!ansible).to.be.true; + }); +}); diff --git a/client/app/services/editor/editor.service.js b/client/app/services/editor/editor.service.js new file mode 100644 index 0000000..39bd220 --- /dev/null +++ b/client/app/services/editor/editor.service.js @@ -0,0 +1,48 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function editorService() { + // Service logic + // ... + + var ui_ace_doctype_map = { + '': 'ini', + 'txt': 'text', + 'text': 'text', + 'yml': 'yaml', + 'yaml': 'yaml', + 'json': 'json', + 'md': 'markdown', + 'html': 'html', + 'py': 'python', + 'j2': 'ini' + }; + + var setContentAndType = function (data, file, selectedFile) { + if (typeof data == 'object') { + selectedFile.content = JSON.stringify(data, null, '\t'); + } else { + selectedFile.content = data; + } + + selectedFile.docType = ui_ace_doctype_map[file.extension.replace('.', '')]; + selectedFile.showSource = true; + + if (selectedFile.docType == 'markdown') { + selectedFile.markdownContent = selectedFile.content; + selectedFile.showSource = false; + } + }; + + // Public API here + return { + ui_ace_doctype_map: ui_ace_doctype_map, + setContentAndType: setContentAndType + }; +} + + +export default angular.module('webAppApp.editor', []) + .factory('editor', editorService) + .name; diff --git a/client/app/services/editor/editor.service.spec.js b/client/app/services/editor/editor.service.spec.js new file mode 100644 index 0000000..d63e286 --- /dev/null +++ b/client/app/services/editor/editor.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: editor', function() { + // load the service's module + beforeEach(module('webAppApp.editor')); + + // instantiate service + var editor; + beforeEach(inject(function(_editor_) { + editor = _editor_; + })); + + it('should do something', function() { + expect(!!editor).to.be.true; + }); +}); diff --git a/client/app/services/projects/projects.service.js b/client/app/services/projects/projects.service.js new file mode 100644 index 0000000..6d8b82a --- /dev/null +++ b/client/app/services/projects/projects.service.js @@ -0,0 +1,20 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function projectsService($resource) { + // AngularJS will instantiate a singleton by calling "new" on this function + this.resource = $resource('/api/projects/:id', { + id: '@_id' + },{ + update: { + method: 'PUT' // this method issues a PUT request + }}); + + this.selectedProject = null; + this.selectedInventoryFileName = null +} + +export default angular.module('webAppApp.projects', []) + .service('Projects', projectsService) + .name; diff --git a/client/app/services/projects/projects.service.spec.js b/client/app/services/projects/projects.service.spec.js new file mode 100644 index 0000000..77f3e59 --- /dev/null +++ b/client/app/services/projects/projects.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: projects', function() { + // load the service's module + beforeEach(module('webAppApp.projects')); + + // instantiate service + var projects; + beforeEach(inject(function(_projects_) { + projects = _projects_; + })); + + it('should do something', function() { + expect(!!projects).to.be.true; + }); +}); diff --git a/client/app/services/yaml/yaml.service.js b/client/app/services/yaml/yaml.service.js new file mode 100644 index 0000000..c204389 --- /dev/null +++ b/client/app/services/yaml/yaml.service.js @@ -0,0 +1,19 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function yamlService() { + // AngularJS will instantiate a singleton by calling "new" on this function + + this.$get = ['$window', function ($window) { + // configure JSONEditor using provider's configuration + console.log("Window"); + + return $window.YAML; + }]; + +} + +export default angular.module('webAppApp.yaml', []) + .factory('YAML', yamlService) + .name; diff --git a/client/app/services/yaml/yaml.service.spec.js b/client/app/services/yaml/yaml.service.spec.js new file mode 100644 index 0000000..0ce3fc1 --- /dev/null +++ b/client/app/services/yaml/yaml.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: yaml', function() { + // load the service's module + beforeEach(module('webAppApp.yaml')); + + // instantiate service + var yaml; + beforeEach(inject(function(_yaml_) { + yaml = _yaml_; + })); + + it('should do something', function() { + expect(!!yaml).to.be.true; + }); +}); diff --git a/client/app/services/yamlFile/yamlFile.service.js b/client/app/services/yamlFile/yamlFile.service.js new file mode 100644 index 0000000..6dbc2d0 --- /dev/null +++ b/client/app/services/yamlFile/yamlFile.service.js @@ -0,0 +1,24 @@ +'use strict'; +const angular = require('angular'); + +/*@ngInject*/ +export function yamlFileService(YAML) { + // AngularJS will instantiate a singleton by calling "new" on this function + this.jsonToYamlFile = function(jsonData, fileDescription){ + + var yamlFilePrefix = ''; + + yamlFilePrefix += '---\n'; + + if(fileDescription) + yamlFilePrefix += '# ' + fileDescription + '\n'; + + var yamlData = yamlFilePrefix + YAML.stringify(JSON.parse(angular.toJson(jsonData)),100); + + return yamlData + } +} + +export default angular.module('webAppApp.yamlFile', []) + .service('yamlFile', yamlFileService) + .name; diff --git a/client/app/services/yamlFile/yamlFile.service.spec.js b/client/app/services/yamlFile/yamlFile.service.spec.js new file mode 100644 index 0000000..120f110 --- /dev/null +++ b/client/app/services/yamlFile/yamlFile.service.spec.js @@ -0,0 +1,16 @@ +'use strict'; + +describe('Service: yamlFile', function() { + // load the service's module + beforeEach(module('webAppApp.yamlFile')); + + // instantiate service + var yamlFile; + beforeEach(inject(function(_yamlFile_) { + yamlFile = _yamlFile_; + })); + + it('should do something', function() { + expect(!!yamlFile).to.be.true; + }); +}); diff --git a/client/assets/fonts/Arual.ttf b/client/assets/fonts/Arual.ttf new file mode 100644 index 0000000000000000000000000000000000000000..14f2650da124908447cee70a31d7eb441e34ec2d GIT binary patch literal 21492 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 YdRTkuJV{3$NG{-;BXz6u?Ql=QKg{sQegFUf literal 0 HcmV?d00001 diff --git a/client/assets/fonts/PUDDLE.otf b/client/assets/fonts/PUDDLE.otf new file mode 100644 index 0000000000000000000000000000000000000000..76795020326cbe68a9072607beee4349bca56318 GIT binary patch literal 25904 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 X)3E(Q%$1I_ly&r>ZW-Pk>kI!64z>=T literal 0 HcmV?d00001 diff --git a/client/assets/fonts/PUDDLE.ttf b/client/assets/fonts/PUDDLE.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1a53b49e0e1977cdfbe2c41288c32b971f1348a2 GIT binary patch literal 39308 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 literal 0 HcmV?d00001 diff --git a/client/assets/images/Button-1-play-icon.png b/client/assets/images/Button-1-play-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..185b122e53bd182e579591009545d1387fd258be GIT binary patch literal 4897 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%0AV3W#AC1&NFRe`*)}^=KP^KtZ9b-S8 ztL#6AZ*oc=l8+D43<{USkxj*N}a8joz>6K$hJVi-qjTOKS+@o)`8f*naCtdMW|=Gfj2-J_=i2Film90FHvl6Sc>Zf%rfnbpjMqRK03N&ExkU zWWt5qMJv~BBMt}PPT?39JW(4pdX7z}dCrBT6jCQ@8@UzRU7iB`H<+KtZ5LXxB9u@C z!XiAGGa8VCn>4H|)ldbR=SVd^&+^q%8?q&-|h39>B!G~52S7Y-$ZsAnI)kN(#qh&RX9(g|J zav#VuU=tYo8nCDy4amW)+O3`sm1@8yyES2s7sRn7;?5-rxP69A6Puc{{$)f^KvmhP zT}!1C$=+{RE0?U;DAj;XMm3Q?a^q0)9S_)~5@2q~vIceNje+?WvaEbLKUwW54_U@F zk=N+asu8BFhQZ#^z}H^MGd_={WMyTO8nlm+Q?63Nh097aMBMp{n&(%8*HyVfIjUK` z5>=^Kjx2)q*}YfJpxtGwHyibKEQMFy;Gli0V16EIo#05^&`BWj6EImwuLf9RjuFaX zD|>csBmB#hl7(z|ANf@eK*r0o>o++HimqYndEjiok93Evv>>1)E)*e64&2(TQ9aS_ z#b%PX?hV{}LT=v`?W(}7y+FV%Lj|sX&B-(8#kQT%QVrhi3iEdX@I8EV%?sYG62J{~Z_0cKUtcG%25W?=;p~lD zqjBHtVb_JPXHi$r-~}8e*arOnAgDO@4kygVK0GngKS8@QZ~oDEHQyo3ods<@dN;0`ayBzzZ8S;7P* z-*rovSdhT=f{~(N5uY5hlYfi&SddUmJX*}GU>PeA;EdTZg#ea+hlcc+sGPD%6vc`*fwk6MBv{|okW-E(%I#S7UL`+>jgqtnsQQYY< zC>=_Fa3bdgw9`sErSkT#R1aMokvZOiEXJoKv&5go6SzPht zCCx9{YEm{LE?>fkyEjpSI4F2mlqQ~w@I+)557Yo|Mm`O=0eB8d{drgBPrj@nrFu%C zc(K>@)imFGw>0k9*)NlkR$52qnb$Vk$90H-NPVF79K9heYV?? z5-i8|6@a7j-Eyac=zI2{LjDP?`)XvM9s)A(Z#>{h;28O6h~Qx!pYQH>_Xq+kh3BkcQx=Jr z(5g+3df|ASQZPO8rg*lrt%@GoY#x2U-U|;fI!BLsr_Lbpbj@opFIV0Dj+XNOjb2VI TZ`)A900000NkvXXu0mjfDU)@A literal 0 HcmV?d00001 diff --git a/client/assets/images/FluSchedule.ics b/client/assets/images/FluSchedule.ics new file mode 100644 index 0000000..1a39a68 --- /dev/null +++ b/client/assets/images/FluSchedule.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +PRODID:-//ddaysoftware.com//NONSGML DDay.iCal 1.0//EN +BEGIN:VEVENT +DESCRIPTION:You have signed up to receive one or more services on Thursday\ + , August 25 2016 at 11:00 AM\n\nWellness Screenings +DTEND:20160825T110000 +DTSTAMP:20160812T142534 +DTSTART:20160825T110000 +LOCATION:New York City\, NY - 2 Penn Plaza\, 17th & 18th Floor +ORGANIZER;CN=Passport Health:mailto:clinics@passporthealthusa.com +SEQUENCE:0 +SUMMARY:Passport Health Reminder +UID:1b13ebdf-8ea1-4207-82f4-4f8bec20de7b +BEGIN:VALARM +ACTION:Display +DESCRIPTION:Passport Health Reminder +TRIGGER:PT30M +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/client/assets/images/ansible_icon.png b/client/assets/images/ansible_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..07cd50529f3407b131be6a14369f27418b05862c GIT binary patch literal 393 zcmV;40e1e0P)kdg0003{NklOxF4>;oX83Srdi&Np<-FjwgaeOK}g19=v5m!{6S&|_0Eu(FjGJz4`w zZhY^<%%XERDj+{qe#gL~i%251Ey?YR;GHOH^30NObr)Y^;RTjV`8%W#gPUYqL@Xo0c;mGG!Skp+$eA2 zJw{jDRus2PRaP2NL11;uL@SLAJi88s7P^CymD{2l{-8`V*+4~hBa4$JkE*8q?JjF- nxxUSc>BUdmKYv_b{=9z!8}Ox~{uV~{00000NkvXXu0mjfGk2l! literal 0 HcmV?d00001 diff --git a/client/assets/images/browser-google-chrome-icon.png b/client/assets/images/browser-google-chrome-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5ae1d4ac5bbcc03fcfb917cae977522527e01379 GIT binary patch literal 6644 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 l%dfKipC5)BWB-}ot0je9qUh=^!4HJ<4c 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 O_>&e_5UUe03jQC~XUIzc literal 0 HcmV?d00001 diff --git a/client/assets/images/inside-emc-black.png b/client/assets/images/inside-emc-black.png new file mode 100644 index 0000000000000000000000000000000000000000..8c65bb105862b2ea4f5332993aee8e84bcb26ac1 GIT binary patch literal 18207 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 literal 0 HcmV?d00001 diff --git a/client/assets/images/inside-emc.png b/client/assets/images/inside-emc.png new file mode 100644 index 0000000000000000000000000000000000000000..78f97fceade934739723ea8006f9812eac36c484 GIT binary patch literal 16235 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 MzWKd6J@@DT2l;i05&!@I literal 0 HcmV?d00001 diff --git a/client/assets/images/inventory.png b/client/assets/images/inventory.png new file mode 100644 index 0000000000000000000000000000000000000000..bf0c1ee0c1a612a30d889a860bd26344ed43f30f GIT binary patch literal 27775 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 literal 0 HcmV?d00001 diff --git a/client/assets/images/migrato.png b/client/assets/images/migrato.png new file mode 100644 index 0000000000000000000000000000000000000000..0d83ddeace7379238f5f802f46753b35c5c1fa31 GIT binary patch literal 17905 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 J%Q~loCIE{-n#lkF literal 0 HcmV?d00001 diff --git a/client/assets/images/play.png b/client/assets/images/play.png new file mode 100644 index 0000000000000000000000000000000000000000..bce6932cda0e2c89ac5cf30cbb361e8d92d2e176 GIT binary patch literal 10946 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 CNo)21 literal 0 HcmV?d00001 diff --git a/client/assets/images/playbook.png b/client/assets/images/playbook.png new file mode 100644 index 0000000000000000000000000000000000000000..81c5570de46e50176d4ff0a7248e49833dfb46cf GIT binary patch literal 51480 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 literal 0 HcmV?d00001 diff --git a/client/assets/images/yeoman.png b/client/assets/images/yeoman.png new file mode 100644 index 0000000000000000000000000000000000000000..7d0a1ac7120ab1e1b06598a02f2670f52aa00829 GIT binary patch literal 12331 zcmV+`Fx1b9P)KLZ*U+5Lu!Sk^o_Z5E4Meg@_7P6crJiNL9pw)e1;Xm069{HJUZAPk55R%$-RIA z6-eL&AQ0xu!e<4=008gy@A0LT~suv4>S3ILP<0Bm`DLLvaF4FK%)Nj?Pt*r}7;7Xa9z9H|HZjR63e zC`Tj$K)V27Re@400>HumpsYY5E(E}?0f1SyGDiY{y#)Yvj#!WnKwtoXnL;eg03bL5 z07D)V%>y7z1E4U{zu>7~aD})?0RX_umCct+(lZpemCzb@^6=o|A>zVpu|i=NDG+7} zl4`aK{0#b-!z=TL9Wt0BGO&T{GJWpjryhdijfaIQ&2!o}p04JRKYg3k&Tf zVxhe-O!X z{f;To;xw^bEES6JSc$k$B2CA6xl)ltA<32E66t?3@gJ7`36pmX0IY^jz)rRYwaaY4 ze(nJRiw;=Qb^t(r^DT@T3y}a2XEZW-_W%Hszxj_qD**t_m!#tW0KDiJT&R>6OvVTR z07RgHDzHHZ48atvzz&?j9lXF70$~P3Knx_nJP<+#`N z#-MZ2bTkiLfR>_b(HgWKJ%F~Nr_oF3b#wrIijHG|(J>BYjM-sajE6;FiC7vY#};Gd zST$CUHDeuEH+B^pz@B062qXfFfD`NpUW5?BY=V%GM_5c)L#QR}BeW8_2v-S%gfYS= zB9o|3v?Y2H`NVi)In3rTB8+ej^> zQ=~r95NVuDChL%G$=>7$vVg20myx%S50Foi`^m%Pw-h?Xh~i8Mq9jtJloCocWk2Nv zrJpiFnV_ms&8eQ$2&#xWpIS+6pmtC%Q-`S&GF4Q#^mhymh7E(qNMa}%YZ-ePrx>>xFPTiH1=E+A$W$=bG8>s^ zm=Bn5Rah$aDtr}@$`X}2l~$F0mFKEdRdZE8)p@E5RI61Ft6o-prbbn>P~)iy)E2AN zsU20jsWz_8Qg>31P|s0cqrPALg8E|(vWA65poU1JRAaZs8I2(p#xiB`SVGovRs-uS zYnV-9TeA7=Om+qP8+I>yOjAR1s%ETak!GFdam@h^# z)@rS0t$wXH+Irf)+G6c;?H29p+V6F6oj{!|o%K3xI`?%6x;DB|x`n#ibhIR?(H}Q3Gzd138Ei2)WAMz7W9Vy`X}HnwgyEn!VS)>mv$8&{hQn>w4zwy3R}t;BYlZQm5)6pty=DfLrs+A-|>>;~;Q z_F?uV_HFjh9n2gO9o9Q^JA86v({H5aB!kjoO6 zc9$1ZZKsN-Zl8L~mE{`ly3)1N^`o1+o7}D0ZPeY&J;i;i`%NyJ8_8Y6J?}yE@b_5a zam?eLr<8@mESk|3$_SkmS{wQ>%qC18))9_|&j{ZT zes8AvOzF(F2#DZEY>2oYX&IRp`F#{ADl)1r>QS^)ba8a|EY_^#S^HO&t^Rgqwv=MZThqqEWH8 zxJo>d=ABlR_Bh=;eM9Tw|Ih34~oTE|= zX_mAr*D$vzw@+p(E0Yc6dFE}(8oqt`+R{gE3x4zjX+Sb3_cYE^= zgB=w+-tUy`ytONMS8KgRef4hA?t0j zufM;t32jm~jUGrkaOInTZ`zyfns>EuS}G30LFK_G-==(f<51|K&cocp&EJ`SxAh3? zNO>#LI=^+SEu(FqJ)ynt=!~PC9bO$rzPJB=?=j6w@a-(u02P7 zaQ)#(uUl{HW%tYNS3ItC^iAtK(eKlL`f9+{bJzISE?u8_z3;~C8@FyI-5j_jy7l;W z_U#vU3hqqYU3!mrul&B+{ptt$59)uk{;_4iZQ%G|z+lhASr6|H35TBkl>gI*;nGLU zN7W-nBaM%pA0HbH8olyl&XeJ%vZoWz%6?Y=dFykl=imL}`%BMQ{Mhgd`HRoLu6e2R za__6DuR6yg#~-}Tc|Gx_{H@O0eebyMy5GmWADJlpK>kqk(fVV@r_fLLKIeS?{4e)} z^ZO;zpECde03c&XQcVB=dL;k=fP(-4`Tqa_faw4Lbua(`>RI+y?e7jKeZ#YO-C zC0I#BK~#9!?45VKT~(R)zt7s`lv`78DhYu=C=w+U1xv7?SZJDv&M@%eI9O(!Q9-Q3 zwT#0kC@RHw?7o9SA`Xh66d@?m%nhA{5J)dKH@W?ubN1SMujl>a?30{aZUW59P4RVq zKKpaZUVD}2_dL&f%Gzs-s!}^dgaha>fYhBj?5w|5J6v||*GCN#p9AX5G~MpY-h#JYM!Nwp2 zgkgA7227kuiJzV3B?40$Z|#1U)j7{Ox6JqblQUqv|F7qqDkuw60KM-Ug>CtG%=lb+ zn;g|{RaHd91Ck&JJ`Q{%BVsU0-Ko`G5s?$EwU6!VIcT3qjw+(B+21rGuvtF`C+3;Y0ve@>nM-M?qBEP1OQJbe*x-Ui>ANj+izqw<+F28ErV5PDu^i6B* z#8K)sgRA~n3fhkPeqr+LV+sW`A7em-q29rNZ+z9opPf5r^4ZU=?7U*YVRIV6OnYVF=rC|{7@m+a z%T1Xs650r;kTR8kEyT2{&vCASS89<6w%C zPyPK$gmW>@YgGT=5T00T;w2dKp*o+(9Pr>!lN@0^C??1$xn_(PTumCk(pC~QiH-O< zOs)v(Fa{agutL|bU#Hr?+1cK$lzX?)|Lnulmwn}k833jseWr{<5Ko42u^pP+O)sU&}M842(}y6qkqziy#IS1Kn%%wmbhQKUuO(@_u3}>pR43S%x3?tU31c z%hv)NI_c2IJfy3L!ACSes6Pcb#N`PP@(6>ZJR=ePq~tJ!2#0EJ2Z+N?m&XXO-H@yW zSm)CB3VaT@#2E99VOGb1Ip-`~xHzr)H=lfT;Ni;`H8$rKmKYZ&V`hit&SxkVj~6^2 zlWzdyA>C{A@nuWp+$&cB>4(4Yz9Wt=Ys5?_ z<8&l5Iz*VIIsRYPyVV4F zf%H?rcfa*S@WV^r()XE`lW(63{N~U}#m_&xYEUP%ip$irO^2q*tW^+sp7%#peXVnD8nAJ1NYJRQz7biNW&mvlP99oz#RZD{NLXl?mjpTW zjFIY0p=oj2Y=2v+bTK@7@8H9i|MO7)&=oUgPWR3|Ws-*Z0-|wGLp~@P=W?ZTiZOyq z5`tVvJ|7TOEWLdZ>Xc$Z@WYVQ=?+*tBe*zbsO&(5)|MRkd`P(x6Gs-~`?yre7d(Q{ zq}MIkw0uK_ro$!|f3|XS5&@)XdX_O}i|2ViPt)}Edoyw{YT=t&e699-aM{-%Ef#af zlu9;5)B{mJ@Wqyf`c&O*O)V`8<3x!DdXmzbhkP+U@zBtAFQ0Gh=^4sxUbh85@G*X# z*jDJ=xPkt@GM;hxL6KBinKi2!W3;O71XK;ruI|UAJ*07o)ESza3$#t0Mwri&#ubtz zrESUtCbYHUoZery@}XJh{ra)2{~i!~p7&l=yKK9D4WNRSpwaL7IZ9u!9Ly)QQG65+iET%8=9TtHkqt%H92JU$I7| zwI~O@b`JT86VNol&*vF8eLDGk4n&CKgf+`oV$vSQO`Ss1_;JKhOpptRQ-|0sY~8el zSx3)dtnV`gr80Xuwh~cjYCw!fCGBHf$7=DDG@3EBv8{W1&u4&tn($PY)Di6CBJ!9q z=9A93c6P3#JMfcjsY?VgVEn4i5kZWPi0a_RRrd`(x3t8j4igP_ZywmNeW0>t6=SBf zFu49{2Du?7 z%zznH-)HpG)b}g_ptAa*xs6k&lG3UsC@5l5msrgWZ4A<`rI&b7yr$GL?T9cx@z4ET zkKNKdzWJnzGfovxv}$E3f)NMpZ7gBw~1uLRG&eB4>D>w;)Z^pNzKjj4C(^BJE2O z1#j)O&%LAf)mw-UZ6p}mBn@HU=e(G$+Xg#q{`DQx|L&V*cx(f}hHGDYf1|0Kv~7KV z+%oGGCg0!*2!(vW;82AP8@JK9wU7RRGHH^M%LSCScHtg>im?;hNRkBK_vzl+i8PI; z^VUBTMKPZ5(b$lut*wPg6Iy9(%oC?dzGxUTd#(-+l;g2en>`x_4+79W-=lp=!p;RB z_W{oU9|V3f+9vgAOO6536=RP+vEeRzRPSAp^A8>J>6zg&lG8wCR@(^!J~A=<(AJ9;ieFo=`4F-2LFw{QkD3EW71#x-xID#@TI| zu9*T%IS4kbgU!GY&{-Ev2!L_Gv2Qz?Q{Qm{uReY@p{KMRI#rtUxqSCvw4rrM%U9uk zV7`MTwXxKsaYQ)P0i0qs)(@KRD zgL)p``d-{YVm~ihAw2a-Wq~IwPIsejllRVPId$ub zzFV6o7d*cZXgpLAF(dcU248`L!5JSh6DF~?AGf=sucF}5&0>aQ#sKcnD@(KIPVy9Gv7@kmHoVBLmweDBIDS-XB6aT9pr*xkZJ zF+x&`M9_5ni~^NGd;OV{@-u;C!N_Z-C1c`cz`;P{Xr>cJ%^;Y-e1RqS_%n(~P{yf` zNIxl$nNl+j7PELHP^&5~bU*T;C5~LI)%445xZhD|Y~vJSoP#qPB`@l zKKAkVFeZXZ*C6@v4NNT>2EO&*{OQ)mnSInmDn0$xxoQMbsu09V5*SW7V@m$-f3>Od z{dwSzw9rFDHf7Q{F$3qo3ViO!efZ{LjQbi&QYEKn4#Qvh$@u7_FQg-`<4yy(v)URb zI+Lt0=onXBVD49Ee5rO%1mpYoxsb#rJh8fu)893hPh9jKieXA$Uq4|XkF8W_X>O(* zdwlE4o4DbZ9lUB*3x42}L>8AMRp(Q8D0u}Efl#5>xZv>r?i}1Y%v7#T{wEq^9x%qd zB}tMyUv7(S)MIP&fDT{|ej%p@hpGd47RO4WnHT0dW)QKe<_on0?SN|Bgs{QD7*7bD zf>Tft6i7=EszTyery4f_TWkFqb_`}jY&;-S20OSZnQAwb!vtuW*~s)cGnx15Ih=UH zVa%95fz&yY#Nl}cQwwl`7*#kB9LdTWw1<1R81uFyNp2f0$r&|p?gsx9NWN_*f4N2c&--fP z$r=O`G)S5;{(auyE$>U$zHeplK~+%Fdzv8URKX~;=<_~@Jnk9p~>&Gx~UT=)K z$2s=~;18n(&ZwEv^O+C8Yh82KiLQ}J8c3JK+~95ZW(3Q5pIQI*%Yun>g~jRoUDc}s zvH-jV3-B(z`tBiM>k)INt)4JrB4fuilFx_OIKjpVk~x+2fIR=d>i@NS^0|<4V;i+? z#zf}KIcPP|`H`#d9%=_Ozc$B>?6(Mj=Xn#d-`nc?eBfAXl38oV=AK@YrSc6J7HXq> z&rL~R=bF5J(yzeQcYOqO$uQX_7ool0d)+ z##Hwrm^U!xr4V>-7WC{sAi-IXap0?v)lW#ewo~F+t0dAVr7YLUAdnjHXuw=El=C(a zr8j3fUF!As{2dZt`WeB~7ZK9f(zWVo{6Zsz=4Om%aH*rx-%nf~WZcX{@WXJ|`6wf) zqJz6MwCAJNuM<@Dv0?Mjfuz>Z1>habnhHJ-0JE|Pj%mhYES~hCZU9W+%&!T|hvPW@ zZk^R;0PSd+ELjwFU?gx<6+f4!al#}vKDv~_&5xrlModVei9Olk2~(d6u=o7z%$^qnhqTn!f6~gj>F&hR-&PkNo;~2`s5lKswQ=s zJ}=-YMg-50q^|T5a#POpyf>*T&bd2BQ>BfnwN^{B04%lT+c%r`favnOdMYbBF%4n$R;Rm=#U%+YO$mz)G>mB_EH>cM z>U>i#E~p}*hXlS1KD37Li+8UF`0dvoe4))D6u>!m9AaeQaCV{B!=8c`e_46Zuz8Ljr@wuKLhhU9!Jk^GW>| zBZ%*TLfqNU(Cy1L{@NepXY~H$vnR?WJUG;;AYTM&e?Gx$Ds84QW;M|6ocki9w0>=` z@3*F^KNOJ**u(ZE0)7lS4oSCF>EpK;(?)ltZR_~)B-<+Vo&PgR7o0%D+m0YSXdI>? zWQUq_iqsp?Su74!v>apC^-%uvTFPHuhSDW6zD-w0mGgM0`|P7~xlcb1r02(@Ej4>fa(1{LzVH8DMBd2GB%;f0Nc zq_YP>@!xm|!SPcGW{tyZEn->=7~f{lzoihT~hqjuhZlHR#-B)h@--VJZbBsrqvkJ4J>lboimzN+TNC&+Tw`IRP~KA z0v9MSJ_F|6+Q^$-GoSFbN;4w;syGj1$l-<*Ghqw}BzJ8hxoah5QmPMg6iD$|5jy7B zRX~E!mbFks#x!8^O}K3dx+wvHUctxVica0l44tu<)qn2cm8Xs2ku{kFNhUR)1H?J^ zrM;zX_ST}p1TaaG+@Y#BW$h-z^9#LM=HNUxjt(<59iU^{#hy$n_x>X z>I9=zL&E_}%m5$tHvo<)o{xxdFedl`{mFLXmj7hxFM+QBSkL<@(C?hPcyG)#`vROa z6OdmnQ+iiM(HpmzU*(}{oY!hk_rHuNZI74tgi0G=U+!x zdL%`TCHNfhbffA#C-Q!voQ+|xfU~#vTidyUUF1gJ_s@1dcB^)`h8D)?Gb2yz6vs_EuVFtNF zh5+R(ZRQ=Wg*#0@jd+}>4O~w*zfVhmauYLkJBj^(Y{(rd{uQfSauIuJC&PWYjbzRG zDH>yz2A+3B4s1VZcy}_3+f5(;Zn~IC$X0fZg(W}(Fb-kOj$Lo6_Qq=onW#QR@z5kC zX)V%M4EbH>W{@N^IXq_KF@O?axGZhMW#7xBT$$!uD_O5q%{_4KTo9oY;df;S}*7eL$m=v*LU0f!%$*>340f z&h@9e@HO9;@*hst#)C_^cok7FjgZaN1ki3puMCv3?NU5oT0zMB)%{p^J`t*vQ`lD0 z9h(W%Z2BgkUjuv$sX9=fB4|IPInRXaoa-`e&c)n7cphD_whh++g_8SmXa)uLwTGDfrDGjhQr*&@8)E2br%> zHk|hcm^J~DG?N;Iz=uuU@WZ=+yn|HgV6IIcsa7W?F9@At)qM@;E~#VQy$;O&w(0jy zB6xhj!}})2G!=kBTmorkw0X4PF$a%kHH>NTh_-ty_+W+io>s;WdEOK3E`b~G$n%q{ z3p7qh>F!P$Fpft^Tm{nUND!&g?b1z&a~A@?8SNz6{yyi77(vKpqaUQCziU;#(kpuY zN2S$&jcZ~YUJ|3?shCELL?{(;lrg++_ICVYnRV+8J%iwTRS6o?00$l9IC(}M#~2z! zHJ2KfQ==7}zY%LLlg?zl##haAw0Bgu&#!s^`+PIj{pm#$^*g0+>~-n?0MzC^L+B zzGu8iB8+$bK!W?(%G7=XSlO`;)!2am&TuIbpkt96d_N3cit;|-bW~o27!QP5rfPpR z1PYHY-F{t8aL6nP7P z#qbP{F9BaVg5c<0lbl?F<0~aS3Yd25pJ~~!P4f4zAOOeOt!jbFZ#%SS^;TJb&Fwq^ zdcQZ2-iNZPW=kvrZ{beUe*ZgEb;r3MAKva5>D*Ik01kri2;fymO=T`H=jbU+2MT*& zO0&NXb_X5@2srgo9IwvZc@YCe+UM^m@39|jga2Z|kpuXhZ47f-M%Y_*cs%F8I1bcz zwv9NJ0}+6i93dEi8;37H))#So__f{D|3XBqe?QKiq(ELmogKC~iPdqQ;~d^dY| z&~_McTeCLPY;4g8Ws6ztuh!q{g4I1gB2wRt+f?vZI`vSGyAUA+P};0?X=cv6h)h@wSC#WN!^)R zYb$XaZ!DL~v$A%BTKm@8k6UY3WOde`tD`7tSJj5P{;vetHIr^a#onfmLsPiyVZvbKA&@ocfy&dthr!|A4~W3&FKwYJAv`zKW$Gi;1l zYnNtawq$fvYwgd6)rHsI-u`?K(79r^iar*RdBCHpdZp)iPkNr`(liwjd2_}AR|5|K zcX^(-NmX;eN)ee4ELYXMNXc8$Hik#`AOiwaq;JG);e1^^JH` zRj(6~dyO&A)k!vhW^{O3M2v{MLRA}$F>S_}8+Ic-Qhh-nD}m!w^p9q3yUbeqw6*ryOwwLELjAk+$6DKKt$jFat3y@85hSR~T6@Fp`oL}~_f$2P zeIJ*pw?S16qbPb`M#QV?8VjH1QLF-SVD7q{wdtBXs+N`yM*4ic3+Hw>{za7pj88ME_K)KCY8(C|=om~%irvk0D z7i8l-HzT@QYnQwLpc69?KOM*MsoDJx)V1TOYTjDg9mnyncGrj9fkQpYwLw{HAJ0DT z9f`wg--m{VrevI>8PQ4^z-q1i{V*mVNs`m*=dF6~8s3*Uj!(_Xo?mxuOh)uCS!@3x ziXx4o=$sKa^$Bb3K&4VS%vu{;Yri#Yt{Kk3636idYwgmyIzEyS{w*WiYw8$Ut+khD z_ncn`P$4Umx7Kctf;0A%kSZ37E{@}n>gvcl zvKUti6h-98x}qsSk|cpK233uR>HXo(CUs`^@SQzWM8*a|@UL+kUj+OZxIMGrNRs3e zs(KW#Fqg}%isN`st2TWgj^p?rRCTI|JewrRzdGmgBGQsg2Vcm>mh8dPue}}k$8j7t zsOnX59REHDf|D`}agXnYdodA$6p%0sZ;a#khgpZV1Np3h-I=QeRlOw)!%q#r7)8+` zB66;C?x*>D{y7nOE(;%>0Q|hpeAf}OrSnu3W6a7r6TCLaTvpEos`|$`j;{%V;G9aO zGRyP4D@5eZAP5#y-Lr9mlb#>UXTQzYD|gT~ybpczbe?UdZ99y+fv){Z8xr9@!0dZyVORT|>;C z*9Khb`~K|Ak9i`q`2Ho1K`=H5g4Q4i#sRC0 zF<;B>DGy_$ZQ1Wq9j*Kz2(A*5j{t9tXGBxi@SX01IeMR45cST5I1cBIC0E z=2~kPipa_!2p$0%t+j8@#`-x`-2xmOMbVaAE?1M(#w1BzDbtGAUj?2})s9>) z_l&jnJ+=NAV~(}fo+Bbp2SM;~Hbq<;$FT#xJB&g0oX;7-VyiiwHAm286K}o6V(KQs zG1l6ltZXuZBe}~N$MKu9`*NyEsZ^S3t-XGC<)bM2O%z3Q>iRgjuAQ{jwaIPnKbb{VvmF01D|g-Q z41yOETgzK(XIE9Z)LxY(X`1%s^ZC`oHI}vZFcI0Y4kmLFcNNL1*!V#ZtgNY`VaCO{ zBuS2N&P@f3F=n&x`^)P>IJL6Awf1lk@$14rCJ2I!z*Ycj?a^TvZp!9~&{{itgz}rR zy2GshUVUIR2!ahjSJrl|Opq~mA|iee1Z#nAYwb~csDGC*RvgE3(=;8(=kw2vKO=9t_aedZc4OMQ7g=^sYXx)<>4M=Q z{TJI_eZAfYzh9{Q3-xnPWxSU#PHzw6+V4^Ze?4A;DgD>uui*Uk80q-G0{|<*uE9Dx RTQC3s002ovPDHLkV1gt^4@v+4 literal 0 HcmV?d00001 diff --git a/client/components/auth/auth.module.js b/client/components/auth/auth.module.js new file mode 100644 index 0000000..c7d4fb0 --- /dev/null +++ b/client/components/auth/auth.module.js @@ -0,0 +1,34 @@ +'use strict'; + +import angular from 'angular'; +import constants from '../../app/app.constants'; +import util from '../util/util.module'; +import ngCookies from 'angular-cookies'; +import { + authInterceptor +} from './interceptor.service'; +import { + routerDecorator +} from './router.decorator'; +import { + AuthService +} from './auth.service'; +import { + UserResource +} from './user.service'; + +import uiRouter from 'angular-ui-router'; + +function addInterceptor($httpProvider) { + 'ngInject'; + + $httpProvider.interceptors.push('authInterceptor'); +} + +export default angular.module('app2App.auth', [constants, util, ngCookies, uiRouter]) + .factory('authInterceptor', authInterceptor) + .run(routerDecorator) + .factory('Auth', AuthService) + .factory('User', UserResource) + .config(['$httpProvider', addInterceptor]) + .name; diff --git a/client/components/auth/auth.service.js b/client/components/auth/auth.service.js new file mode 100644 index 0000000..a5fd3fa --- /dev/null +++ b/client/components/auth/auth.service.js @@ -0,0 +1,226 @@ +'use strict'; + +import * as _ from 'lodash'; + + +class _User { + _id = ''; + name = ''; + email = ''; + role = ''; + $promise = undefined; +} + +export function AuthService($location, $http, $cookies, $q, appConfig, Util, User) { + 'ngInject'; + + var safeCb = Util.safeCb; + var currentUser = new _User(); + var userRoles = appConfig.userRoles || []; + /** + * Check if userRole is >= role + * @param {String} userRole - role of current user + * @param {String} role - role to check against + */ + var hasRole = function(userRole, role) { + return userRoles.indexOf(userRole) >= userRoles.indexOf(role); + }; + + if($cookies.get('token') && $location.path() !== '/logout') { + currentUser = User.get(); + } + + var Auth = { + /** + * Authenticate user and save token + * + * @param {Object} user - login info + * @param {Function} callback - function(error, user) + * @return {Promise} + */ + login({ + email, + password + }, callback) { + return $http.post('/auth/local', { + email, + password + }) + .then(res => { + $cookies.put('token', res.data.token); + currentUser = User.get(); + return currentUser.$promise; + }) + .then(user => { + safeCb(callback)(null, user); + return user; + }) + .catch(err => { + Auth.logout(); + safeCb(callback)(err.data); + return $q.reject(err.data); + }); + }, + + /** + * Delete access token and user info + */ + logout() { + $cookies.remove('token'); + currentUser = new _User(); + }, + + /** + * Create a new user + * + * @param {Object} user - user info + * @param {Function} callback - function(error, user) + * @return {Promise} + */ + createUser(user, callback) { + return User.save(user, function(data) { + $cookies.put('token', data.token); + currentUser = User.get(); + return safeCb(callback)(null, user); + }, function(err) { + Auth.logout(); + return safeCb(callback)(err); + }) + .$promise; + }, + + /** + * Change password + * + * @param {String} oldPassword + * @param {String} newPassword + * @param {Function} callback - function(error, user) + * @return {Promise} + */ + changePassword(oldPassword, newPassword, callback) { + return User.changePassword({ + id: currentUser._id + }, { + oldPassword, + newPassword + }, function() { + return safeCb(callback)(null); + }, function(err) { + return safeCb(callback)(err); + }) + .$promise; + }, + + /** + * Gets all available info on a user + * + * @param {Function} [callback] - function(user) + * @return {Promise} + */ + getCurrentUser(callback) { + var value = _.get(currentUser, '$promise') ? currentUser.$promise : currentUser; + + return $q.when(value) + .then(user => { + safeCb(callback)(user); + return user; + }, () => { + safeCb(callback)({}); + return {}; + }); + }, + + /** + * Gets all available info on a user + * + * @return {Object} + */ + getCurrentUserSync() { + return currentUser; + }, + + /** + * Check if a user is logged in + * + * @param {Function} [callback] - function(is) + * @return {Promise} + */ + isLoggedIn(callback) { + return Auth.getCurrentUser(undefined) + .then(user => { + let is = _.get(user, 'role'); + + safeCb(callback)(is); + return is; + }); + }, + + /** + * Check if a user is logged in + * + * @return {Bool} + */ + isLoggedInSync() { + return !!_.get(currentUser, 'role'); + }, + + /** + * Check if a user has a specified role or higher + * + * @param {String} role - the role to check against + * @param {Function} [callback] - function(has) + * @return {Promise} + */ + hasRole(role, callback) { + return Auth.getCurrentUser(undefined) + .then(user => { + let has = hasRole(_.get(user, 'role'), role); + + safeCb(callback)(has); + return has; + }); + }, + + /** + * Check if a user has a specified role or higher + * + * @param {String} role - the role to check against + * @return {Bool} + */ + hasRoleSync(role) { + return hasRole(_.get(currentUser, 'role'), role); + }, + + /** + * Check if a user is an admin + * (synchronous|asynchronous) + * + * @param {Function|*} callback - optional, function(is) + * @return {Bool|Promise} + */ + isAdmin(...args) { + return Auth.hasRole(Reflect.apply([].concat, ['admin'], args)); + }, + + /** + * Check if a user is an admin + * + * @return {Bool} + */ + isAdminSync() { + // eslint-disable-next-line no-sync + return Auth.hasRoleSync('admin'); + }, + + /** + * Get auth token + * + * @return {String} - a token string used for authenticating + */ + getToken() { + return $cookies.get('token'); + } + }; + + return Auth; +} diff --git a/client/components/auth/interceptor.service.js b/client/components/auth/interceptor.service.js new file mode 100644 index 0000000..a04e7ff --- /dev/null +++ b/client/components/auth/interceptor.service.js @@ -0,0 +1,28 @@ +'use strict'; + +export function authInterceptor($rootScope, $q, $cookies, $injector, Util) { + 'ngInject'; + + var state; + return { + // Add authorization token to headers + request(config) { + config.headers = config.headers || {}; + if($cookies.get('token') && Util.isSameOrigin(config.url)) { + config.headers.Authorization = `Bearer ${$cookies.get('token')}`; + } + return config; + }, + + // Intercept 401s and redirect you to login + responseError(response) { + if(response.status === 401) { + (state || (state = $injector.get('$state'))) + .go('login'); + // remove any stale tokens + $cookies.remove('token'); + } + return $q.reject(response); + } + }; +} diff --git a/client/components/auth/router.decorator.js b/client/components/auth/router.decorator.js new file mode 100644 index 0000000..4e23192 --- /dev/null +++ b/client/components/auth/router.decorator.js @@ -0,0 +1,38 @@ +'use strict'; + +export function routerDecorator($rootScope, $state, Auth) { + 'ngInject'; + // Redirect to login if route requires auth and the user is not logged in, or doesn't have required role + + $rootScope.$on('$stateChangeStart', function(event, next) { + if(!next.authenticate) { + return; + } + + if(typeof next.authenticate === 'string') { + Auth.hasRole(next.authenticate) + .then(has => { + if(has) { + return; + } + + event.preventDefault(); + return Auth.isLoggedIn() + .then(is => { + $state.go(is ? 'main' : 'login'); + }); + }); + } else { + Auth.isLoggedIn() + .then(is => { + if(is) { + return; + } + + event.preventDefault(); + + $state.go('login'); + }); + } + }); +} diff --git a/client/components/auth/user.service.js b/client/components/auth/user.service.js new file mode 100644 index 0000000..4204298 --- /dev/null +++ b/client/components/auth/user.service.js @@ -0,0 +1,22 @@ +'use strict'; + +export function UserResource($resource) { + 'ngInject'; + + return $resource('/api/users/:id/:controller', { + id: '@_id' + }, { + changePassword: { + method: 'PUT', + params: { + controller: 'password' + } + }, + get: { + method: 'GET', + params: { + id: 'me' + } + } + }); +} diff --git a/client/components/footer/footer.component.js b/client/components/footer/footer.component.js new file mode 100644 index 0000000..64bf5a1 --- /dev/null +++ b/client/components/footer/footer.component.js @@ -0,0 +1,10 @@ +import angular from 'angular'; + +export class FooterComponent {} + +export default angular.module('directives.footer', []) + .component('footer', { + template: require('./footer.html'), + controller: FooterComponent + }) + .name; diff --git a/client/components/footer/footer.css b/client/components/footer/footer.css new file mode 100644 index 0000000..cd1753e --- /dev/null +++ b/client/components/footer/footer.css @@ -0,0 +1,6 @@ +footer.footer { + text-align: center; + padding: 30px 0; + margin-top: 70px; + border-top: 1px solid #E5E5E5; +} diff --git a/client/components/footer/footer.html b/client/components/footer/footer.html new file mode 100644 index 0000000..ab3b060 --- /dev/null +++ b/client/components/footer/footer.html @@ -0,0 +1,8 @@ +

diff --git a/client/components/modal/modal.css b/client/components/modal/modal.css new file mode 100644 index 0000000..ae04068 --- /dev/null +++ b/client/components/modal/modal.css @@ -0,0 +1,23 @@ +.modal-primary .modal-header, +.modal-info .modal-header, +.modal-success .modal-header, +.modal-warning .modal-header, +.modal-danger .modal-header { + color: #fff; + border-radius: 5px 5px 0 0; +} +.modal-primary .modal-header { + background: #428bca; +} +.modal-info .modal-header { + background: #5bc0de; +} +.modal-success .modal-header { + background: #5cb85c; +} +.modal-warning .modal-header { + background: #f0ad4e; +} +.modal-danger .modal-header { + background: #d9534f; +} diff --git a/client/components/modal/modal.html b/client/components/modal/modal.html new file mode 100644 index 0000000..f04d0db --- /dev/null +++ b/client/components/modal/modal.html @@ -0,0 +1,11 @@ + + + diff --git a/client/components/modal/modal.service.js b/client/components/modal/modal.service.js new file mode 100644 index 0000000..3941b64 --- /dev/null +++ b/client/components/modal/modal.service.js @@ -0,0 +1,78 @@ +'use strict'; + +import angular from 'angular'; + +export function Modal($rootScope, $uibModal) { + /** + * Opens a modal + * @param {Object} scope - an object to be merged with modal's scope + * @param {String} modalClass - (optional) class(es) to be applied to the modal + * @return {Object} - the instance $uibModal.open() returns + */ + function openModal(scope = {}, modalClass = 'modal-default') { + var modalScope = $rootScope.$new(); + + angular.extend(modalScope, scope); + + return $uibModal.open({ + template: require('./modal.html'), + windowClass: modalClass, + scope: modalScope + }); + } + + // Public API here + return { + + /* Confirmation modals */ + confirm: { + + /** + * Create a function to open a delete confirmation modal (ex. ng-click='myModalFn(name, arg1, arg2...)') + * @param {Function} del - callback, ran when delete is confirmed + * @return {Function} - the function to open the modal (ex. myModalFn) + */ + delete(del = angular.noop) { + /** + * Open a delete confirmation modal + * @param {String} name - name or info to show on modal + * @param {All} - any additional args are passed straight to del callback + */ + return function(...args) { + var slicedArgs = Reflect.apply(Array.prototype.slice, args); + var name = slicedArgs.shift(); + var deleteModal; + + deleteModal = openModal({ + modal: { + dismissable: true, + title: 'Confirm Delete', + html: `

Are you sure you want to delete ${name} ?

`, + buttons: [{ + classes: 'btn-danger', + text: 'Delete', + click(e) { + deleteModal.close(e); + } + }, { + classes: 'btn-default', + text: 'Cancel', + click(e) { + deleteModal.dismiss(e); + } + }] + } + }, 'modal-danger'); + + deleteModal.result.then(function(event) { + Reflect.apply(del, event, slicedArgs); + }); + }; + } + } + }; +} + +export default angular.module('app2App.Modal', []) + .factory('Modal', Modal) + .name; diff --git a/client/components/mongoose-error/mongoose-error.directive.js b/client/components/mongoose-error/mongoose-error.directive.js new file mode 100644 index 0000000..057e1cb --- /dev/null +++ b/client/components/mongoose-error/mongoose-error.directive.js @@ -0,0 +1,17 @@ +'use strict'; + +import angular from 'angular'; + +/** + * Removes server error when user updates input + */ +angular.module('app2App') + .directive('mongooseError', function() { + return { + restrict: 'A', + require: 'ngModel', + link(scope, element, attrs, ngModel) { + element.on('keydown', () => ngModel.$setValidity('mongoose', true)); + } + }; + }); diff --git a/client/components/navbar/navbar.component.js b/client/components/navbar/navbar.component.js new file mode 100644 index 0000000..8387bdb --- /dev/null +++ b/client/components/navbar/navbar.component.js @@ -0,0 +1,41 @@ +'use strict'; +/* eslint no-sync: 0 */ + +import angular from 'angular'; + +export class NavbarComponent { + menu = [{ + 'title': 'Home', + 'state': 'main' + },{ + 'title': 'Projects', + 'state': 'project' + },{ + 'title': 'Designer', + 'state': 'designer' + },{ + 'title': 'Runs', + 'state': 'runs' + },{ + 'title': 'Modules', + 'state': 'custom_modules' + }]; + + isCollapsed = true; + + constructor(Auth) { + 'ngInject'; + + this.isLoggedIn = Auth.isLoggedInSync; + this.isAdmin = Auth.isAdminSync; + this.getCurrentUser = Auth.getCurrentUserSync; + } + +} + +export default angular.module('directives.navbar', []) + .component('navbar', { + template: require('./navbar.html'), + controller: NavbarComponent + }) + .name; diff --git a/client/components/navbar/navbar.html b/client/components/navbar/navbar.html new file mode 100644 index 0000000..05e99b0 --- /dev/null +++ b/client/components/navbar/navbar.html @@ -0,0 +1,29 @@ + diff --git a/client/components/oauth-buttons/index.js b/client/components/oauth-buttons/index.js new file mode 100644 index 0000000..094e540 --- /dev/null +++ b/client/components/oauth-buttons/index.js @@ -0,0 +1,25 @@ +'use strict'; + +import angular from 'angular'; + +export function OauthButtonsController($window) { + 'ngInject'; + + this.loginOauth = function(provider) { + $window.location.href = `/auth/${provider}`; + }; +} + +export default angular.module('app2App.oauthButtons', []) + .directive('oauthButtons', function() { + return { + template: require('./oauth-buttons.html'), + restrict: 'EA', + controller: OauthButtonsController, + controllerAs: 'OauthButtons', + scope: { + classes: '@' + } + }; + }) + .name; diff --git a/client/components/oauth-buttons/oauth-buttons.controller.spec.js b/client/components/oauth-buttons/oauth-buttons.controller.spec.js new file mode 100644 index 0000000..9af01f9 --- /dev/null +++ b/client/components/oauth-buttons/oauth-buttons.controller.spec.js @@ -0,0 +1,32 @@ +'use strict'; + +import { + OauthButtonsController +} from './index'; + +describe('Controller: OauthButtonsController', function() { + var controller, $window; + + beforeEach(() => { + angular.module('test', []) + .controller('OauthButtonsController', OauthButtonsController); + }); + // load the controller's module + beforeEach(angular.mock.module('test')); + + // Initialize the controller and a mock $window + beforeEach(inject(function($controller) { + $window = { + location: {} + }; + + controller = $controller('OauthButtonsController', { + $window + }); + })); + + it('should attach loginOauth', function() { + expect(controller.loginOauth) + .to.be.a('function'); + }); +}); diff --git a/client/components/oauth-buttons/oauth-buttons.css b/client/components/oauth-buttons/oauth-buttons.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/client/components/oauth-buttons/oauth-buttons.css @@ -0,0 +1 @@ + diff --git a/client/components/oauth-buttons/oauth-buttons.directive.spec.js b/client/components/oauth-buttons/oauth-buttons.directive.spec.js new file mode 100644 index 0000000..5434a4f --- /dev/null +++ b/client/components/oauth-buttons/oauth-buttons.directive.spec.js @@ -0,0 +1,59 @@ +'use strict'; + +const $ = require('sprint-js'); +import OauthButtons from './index'; + +describe('Directive: oauthButtons', function() { + // load the directive's module and view + beforeEach(angular.mock.module(OauthButtons)); + // beforeEach(angular.mock.module('components/oauth-buttons/oauth-buttons.html')); + + var element, parentScope, elementScope; + + var compileDirective = function(template) { + inject(function($compile) { + element = angular.element(template); + element = $compile(element)(parentScope); + parentScope.$digest(); + elementScope = element.isolateScope(); + }); + }; + + beforeEach(inject(function($rootScope) { + parentScope = $rootScope.$new(); + })); + + it('should contain anchor buttons', function() { + compileDirective(''); + expect($(element[0]) + .find('a.btn.btn-social') + .length) + .to.be.at.least(1); + }); + + it('should evaluate and bind the classes attribute to scope.classes', function() { + parentScope.scopedClass = 'scopedClass1'; + compileDirective(''); + expect(elementScope.classes) + .to.equal('testClass1 scopedClass1'); + }); + + it('should bind scope.classes to class names on the anchor buttons', function() { + compileDirective(''); + // Add classes + elementScope.classes = 'testClass1 testClass2'; + elementScope.$digest(); + expect($(element[0]) + .find('a.btn.btn-social.testClass1.testClass2') + .length) + .to.be.at.least(1); + + // Remove classes + elementScope.classes = ''; + elementScope.$digest(); + expect($(element[0]) + .find('a.btn.btn-social.testClass1.testClass2') + .length) + .to.equal(0); + }); +}); diff --git a/client/components/oauth-buttons/oauth-buttons.html b/client/components/oauth-buttons/oauth-buttons.html new file mode 100644 index 0000000..ce6026a --- /dev/null +++ b/client/components/oauth-buttons/oauth-buttons.html @@ -0,0 +1,9 @@ + + + Connect with Facebook + + + + Connect with Google+ + + diff --git a/client/components/ui-router/ui-router.mock.js b/client/components/ui-router/ui-router.mock.js new file mode 100644 index 0000000..3e2ba06 --- /dev/null +++ b/client/components/ui-router/ui-router.mock.js @@ -0,0 +1,38 @@ +'use strict'; + +const angular = require('angular'); + +angular.module('stateMock', []); +angular.module('stateMock') + .service('$state', function($q) { + this.expectedTransitions = []; + + this.transitionTo = function(stateName) { + if(this.expectedTransitions.length > 0) { + var expectedState = this.expectedTransitions.shift(); + if(expectedState !== stateName) { + throw Error(`Expected transition to state: ${expectedState + } but transitioned to ${stateName}`); + } + } else { + throw Error(`No more transitions were expected! Tried to transition to ${stateName}`); + } + console.log(`Mock transition to: ${stateName}`); + var deferred = $q.defer(); + var promise = deferred.promise; + deferred.resolve(); + return promise; + }; + + this.go = this.transitionTo; + + this.expectTransitionTo = function(stateName) { + this.expectedTransitions.push(stateName); + }; + + this.ensureAllTransitionsHappened = function() { + if(this.expectedTransitions.length > 0) { + throw Error('Not all transitions happened!'); + } + }; + }); diff --git a/client/components/util/util.module.js b/client/components/util/util.module.js new file mode 100644 index 0000000..6f3b8a4 --- /dev/null +++ b/client/components/util/util.module.js @@ -0,0 +1,10 @@ +'use strict'; + +import angular from 'angular'; +import { + UtilService +} from './util.service'; + +export default angular.module('app2App.util', []) + .factory('Util', UtilService) + .name; diff --git a/client/components/util/util.service.js b/client/components/util/util.service.js new file mode 100644 index 0000000..d481da5 --- /dev/null +++ b/client/components/util/util.service.js @@ -0,0 +1,68 @@ +'use strict'; + +import angular from 'angular'; + +/** + * The Util service is for thin, globally reusable, utility functions + */ +export function UtilService($window) { + 'ngInject'; + + var Util = { + /** + * Return a callback or noop function + * + * @param {Function|*} cb - a 'potential' function + * @return {Function} + */ + safeCb(cb) { + return angular.isFunction(cb) ? cb : angular.noop; + }, + + /** + * Parse a given url with the use of an anchor element + * + * @param {String} url - the url to parse + * @return {Object} - the parsed url, anchor element + */ + urlParse(url) { + var a = document.createElement('a'); + a.href = url; + + // Special treatment for IE, see http://stackoverflow.com/a/13405933 for details + if(a.host === '') { + a.href = a.href; + } + + return a; + }, + + /** + * Test whether or not a given url is same origin + * + * @param {String} url - url to test + * @param {String|String[]} [origins] - additional origins to test against + * @return {Boolean} - true if url is same origin + */ + isSameOrigin(url, origins) { + url = Util.urlParse(url); + origins = origins && [].concat(origins) || []; + origins = origins.map(Util.urlParse); + origins.push($window.location); + origins = origins.filter(function(o) { + let hostnameCheck = url.hostname === o.hostname; + let protocolCheck = url.protocol === o.protocol; + // 2nd part of the special treatment for IE fix (see above): + // This part is when using well-known ports 80 or 443 with IE, + // when $window.location.port==='' instead of the real port number. + // Probably the same cause as this IE bug: https://goo.gl/J9hRta + let portCheck = url.port === o.port || o.port === '' && (url.port === '80' || url.port + === '443'); + return hostnameCheck && protocolCheck && portCheck; + }); + return origins.length >= 1; + } + }; + + return Util; +} diff --git a/client/favicon.ico b/client/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8a163fbc628488b0f58bc23dc517294ce9aff557 GIT binary patch literal 6774 zcmeHL&1w`u5bl`l2C`_PXh1Qz8d+i>B4Yd@7`=E<2!h~2FuDjHyvX{4guUeITjWV3 zZ{cm9!B?<)YO24cx_hT*W|2LZ%v4uZ*Uwiqz1_2G%mV%Q`^Hl9=k(W_G3&;dExJi* zp3-e+ia(mW(BqpGA?>d2++Du>a&+VIh=RTNPQ)8OusDYl$TViI#MWeM3oB*~%@66O z%5rG$=3B2TIqmr2jKDe76h^Ayj5*kx%~03sW+-1>zQ6aGyuLZ4`yV=5 zqvwo3091@J)@Po9&w;}a4!htTyOlP;RJRwB2bqBe98kyWyTxEfgJWhk>@i7~$Sh5# z>9b09d%?Q-i6h;%D}pW=MK5;Zr$=O(j)-Ta23?cXte-X3f_Uz@G#B4Hwia{ftid@I zF%O(jHr~l00}FWhR>%O(pU>f?PE-yK{n4Gf`sMde>9QVkvZice70X(|T(!6Opl{XQ zsi?kQY7N(m?TGHuLw4N~FBzY^koYWv!?OhX@?a-F*r6h+7M`#Ihgq>l1Xv*xvcnU0 z;7|=#4mjz7?v0iAxDP{EAGppSdyJEPWc?L8oGZ6IEysLgx|F}flYB8>=zt!a)EKM3 z)9DcWIj3Wde9rMwN4uRBqCRf>W)viOU=jH1sU zCnqWVT2J+y=Evp!$NTNCCLQt08T<04wbklD^_rlq;{!h(W<`jl~~8kqR-qb zIJ5k9P-3O$I`h1ZV`_X_PMsD1zJl;<)mZ=cxBXLz_0-qd&tv;6?IZY@rty@i;Xc+9 zW93euQS+}REwg6s139~@#^=ip>uh>EuKN8^a#MN6tQK@Jpv#4ZDcdj~TKbxl&9x$0 SNI7y2W#1=7oMTJv>*HS$DU+}O literal 0 HcmV?d00001 diff --git a/client/polyfills.js b/client/polyfills.js new file mode 100644 index 0000000..314b929 --- /dev/null +++ b/client/polyfills.js @@ -0,0 +1,28 @@ +// Polyfills +// (these modules are what are in 'angular2/bundles/angular2-polyfills' so don't use that here) + +// import 'ie-shim'; // Internet Explorer +// import 'es6-shim'; +// import 'es6-promise'; +// import 'es7-reflect-metadata'; + +// Prefer CoreJS over the polyfills above +import 'core-js/es6'; +import 'core-js/es7/reflect'; +// require('zone.js/dist/zone'); + + +if(!ENV) { + var ENV = 'development'; +} + +if(ENV === 'production') { + // Production +} else { + // Development + + + Error.stackTraceLimit = Infinity; + + // require('zone.js/dist/long-stack-trace-zone'); +} diff --git a/client/robots.txt b/client/robots.txt new file mode 100644 index 0000000..9417495 --- /dev/null +++ b/client/robots.txt @@ -0,0 +1,3 @@ +# robotstxt.org + +User-agent: * diff --git a/e2e/account/login/login.po.js b/e2e/account/login/login.po.js new file mode 100644 index 0000000..98e2b98 --- /dev/null +++ b/e2e/account/login/login.po.js @@ -0,0 +1,28 @@ +/** + * This file uses the Page Object pattern to define the main page for tests + * https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ + */ + +'use strict'; + +var LoginPage = function() { + var form = this.form = element(by.css('.form')); + form.email = form.element(by.model('vm.user.email')); + form.password = form.element(by.model('vm.user.password')); + form.submit = form.element(by.css('.btn-login')); + form.oauthButtons = require('../../components/oauth-buttons/oauth-buttons.po').oauthButtons; + + this.login = function(data) { + for (var prop in data) { + var formElem = form[prop]; + if (data.hasOwnProperty(prop) && formElem && typeof formElem.sendKeys === 'function') { + formElem.sendKeys(data[prop]); + } + } + + return form.submit.click(); + }; +}; + +module.exports = new LoginPage(); + diff --git a/e2e/account/login/login.spec.js b/e2e/account/login/login.spec.js new file mode 100644 index 0000000..8c5be9d --- /dev/null +++ b/e2e/account/login/login.spec.js @@ -0,0 +1,87 @@ +'use strict'; + +var config = browser.params; +var UserModel = require(config.serverConfig.root + '/server/api/user/user.model').default; + +describe('Login View', function() { + var page; + + var loadPage = function() { + let promise = browser.get(config.baseUrl + '/login'); + page = require('./login.po'); + return promise; + }; + + var testUser = { + name: 'Test User', + email: 'test@example.com', + password: 'test' + }; + + before(function() { + return UserModel + .remove() + .then(function() { + return UserModel.create(testUser); + }) + .then(loadPage); + }); + + after(function() { + return UserModel.remove(); + }); + + it('should include login form with correct inputs and submit button', function() { + expect(page.form.email.getAttribute('type')).to.eventually.equal('email'); + expect(page.form.email.getAttribute('name')).to.eventually.equal('email'); + expect(page.form.password.getAttribute('type')).to.eventually.equal('password'); + expect(page.form.password.getAttribute('name')).to.eventually.equal('password'); + expect(page.form.submit.getAttribute('type')).to.eventually.equal('submit'); + expect(page.form.submit.getText()).to.eventually.equal('Login'); + }); + + it('should include oauth buttons with correct classes applied', function() { + expect(page.form.oauthButtons.facebook.getText()).to.eventually.equal('Connect with Facebook'); + expect(page.form.oauthButtons.facebook.getAttribute('class')).to.eventually.contain('btn-block'); + expect(page.form.oauthButtons.google.getText()).to.eventually.equal('Connect with Google+'); + expect(page.form.oauthButtons.google.getAttribute('class')).to.eventually.contain('btn-block'); + }); + + describe('with local auth', function() { + + it('should login a user and redirecting to "/"', function() { + return page.login(testUser).then(() => { + var navbar = require('../../components/navbar/navbar.po'); + + return browser.wait( + () => element(by.css('.hero-unit')), + 5000, + `Didn't find .hero-unit after 5s` + ).then(() => { + expect(browser.getCurrentUrl()).to.eventually.equal(config.baseUrl + '/'); + expect(navbar.navbarAccountGreeting.getText()).to.eventually.equal('Hello ' + testUser.name); + }); + }); + }); + + describe('and invalid credentials', function() { + before(function() { + return loadPage(); + }) + + it('should indicate login failures', function() { + page.login({ + email: testUser.email, + password: 'badPassword' + }); + + expect(browser.getCurrentUrl()).to.eventually.equal(config.baseUrl + '/login'); + + var helpBlock = page.form.element(by.css('.form-group.has-error .help-block.ng-binding')); + expect(helpBlock.getText()).to.eventually.equal('This password is not correct.'); + }); + + }); + + }); +}); diff --git a/e2e/account/logout/logout.spec.js b/e2e/account/logout/logout.spec.js new file mode 100644 index 0000000..816e5a0 --- /dev/null +++ b/e2e/account/logout/logout.spec.js @@ -0,0 +1,51 @@ +'use strict'; + +var config = browser.params; +var UserModel = require(config.serverConfig.root + '/server/api/user/user.model').default; + +describe('Logout View', function() { + var login = function(user) { + let promise = browser.get(config.baseUrl + '/login'); + require('../login/login.po').login(user); + return promise; + }; + + var testUser = { + name: 'Test User', + email: 'test@example.com', + password: 'test' + }; + + beforeEach(function() { + return UserModel + .remove() + .then(function() { + return UserModel.create(testUser); + }) + .then(function() { + return login(testUser); + }); + }); + + after(function() { + return UserModel.remove(); + }) + + describe('with local auth', function() { + + it('should logout a user and redirecting to "/"', function() { + var navbar = require('../../components/navbar/navbar.po'); + + expect(browser.getCurrentUrl()).to.eventually.equal(config.baseUrl + '/'); + expect(navbar.navbarAccountGreeting.getText()).to.eventually.equal('Hello ' + testUser.name); + + browser.get(config.baseUrl + '/logout'); + + navbar = require('../../components/navbar/navbar.po'); + + expect(browser.getCurrentUrl()).to.eventually.equal(config.baseUrl + '/'); + expect(navbar.navbarAccountGreeting.isDisplayed()).to.eventually.equal(false); + }); + + }); +}); diff --git a/e2e/account/signup/signup.po.js b/e2e/account/signup/signup.po.js new file mode 100644 index 0000000..9bfee98 --- /dev/null +++ b/e2e/account/signup/signup.po.js @@ -0,0 +1,30 @@ +/** + * This file uses the Page Object pattern to define the main page for tests + * https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ + */ + +'use strict'; + +var SignupPage = function() { + var form = this.form = element(by.css('.form')); + form.name = form.element(by.model('vm.user.name')); + form.email = form.element(by.model('vm.user.email')); + form.password = form.element(by.model('vm.user.password')); + form.confirmPassword = form.element(by.model('vm.user.confirmPassword')); + form.submit = form.element(by.css('.btn-register')); + form.oauthButtons = require('../../components/oauth-buttons/oauth-buttons.po').oauthButtons; + + this.signup = function(data) { + for (var prop in data) { + var formElem = form[prop]; + if (data.hasOwnProperty(prop) && formElem && typeof formElem.sendKeys === 'function') { + formElem.sendKeys(data[prop]); + } + } + + return form.submit.click(); + }; +}; + +module.exports = new SignupPage(); + diff --git a/e2e/account/signup/signup.spec.js b/e2e/account/signup/signup.spec.js new file mode 100644 index 0000000..1c5ca6b --- /dev/null +++ b/e2e/account/signup/signup.spec.js @@ -0,0 +1,84 @@ +'use strict'; + +var config = browser.params; +var UserModel = require(config.serverConfig.root + '/server/api/user/user.model').default; + +describe('Signup View', function() { + var page; + + var loadPage = function() { + browser.manage().deleteAllCookies() + let promise = browser.get(config.baseUrl + '/signup'); + page = require('./signup.po'); + return promise; + }; + + var testUser = { + name: 'Test', + email: 'test@example.com', + password: 'test', + confirmPassword: 'test' + }; + + before(function() { + return loadPage(); + }); + + after(function() { + return UserModel.remove(); + }); + + it('should include signup form with correct inputs and submit button', function() { + expect(page.form.name.getAttribute('type')).to.eventually.equal('text'); + expect(page.form.name.getAttribute('name')).to.eventually.equal('name'); + expect(page.form.email.getAttribute('type')).to.eventually.equal('email'); + expect(page.form.email.getAttribute('name')).to.eventually.equal('email'); + expect(page.form.password.getAttribute('type')).to.eventually.equal('password'); + expect(page.form.password.getAttribute('name')).to.eventually.equal('password'); + expect(page.form.confirmPassword.getAttribute('type')).to.eventually.equal('password'); + expect(page.form.confirmPassword.getAttribute('name')).to.eventually.equal('confirmPassword'); + expect(page.form.submit.getAttribute('type')).to.eventually.equal('submit'); + expect(page.form.submit.getText()).to.eventually.equal('Sign up'); + }); + + it('should include oauth buttons with correct classes applied', function() { + expect(page.form.oauthButtons.facebook.getText()).to.eventually.equal('Connect with Facebook'); + expect(page.form.oauthButtons.facebook.getAttribute('class')).to.eventually.contain('btn-block'); + expect(page.form.oauthButtons.google.getText()).to.eventually.equal('Connect with Google+'); + expect(page.form.oauthButtons.google.getAttribute('class')).to.eventually.contain('btn-block'); + }); + + describe('with local auth', function() { + + before(function() { + return UserModel.remove(); + }) + + it('should signup a new user, log them in, and redirecting to "/"', function() { + page.signup(testUser); + + var navbar = require('../../components/navbar/navbar.po'); + + expect(browser.getCurrentUrl()).to.eventually.equal(config.baseUrl + '/'); + expect(navbar.navbarAccountGreeting.getText()).to.eventually.equal('Hello ' + testUser.name); + }); + + describe('and invalid credentials', function() { + before(function() { + return loadPage(); + }); + + it('should indicate signup failures', function() { + page.signup(testUser); + + expect(browser.getCurrentUrl()).to.eventually.equal(config.baseUrl + '/signup'); + expect(page.form.email.getAttribute('class')).to.eventually.contain('ng-invalid-mongoose'); + + var helpBlock = page.form.element(by.css('.form-group.has-error .help-block.ng-binding')); + expect(helpBlock.getText()).to.eventually.equal('The specified email address is already in use.'); + }); + + }); + + }); +}); diff --git a/e2e/components/navbar/navbar.po.js b/e2e/components/navbar/navbar.po.js new file mode 100644 index 0000000..b904342 --- /dev/null +++ b/e2e/components/navbar/navbar.po.js @@ -0,0 +1,16 @@ +/** + * This file uses the Page Object pattern to define the main page for tests + * https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ + */ + +'use strict'; + +var NavbarComponent = function() { + this.navbar = element(by.css('.navbar')); + this.navbarHeader = this.navbar.element(by.css('.navbar-header')); + this.navbarNav = this.navbar.element(by.css('#navbar-main .nav.navbar-nav:not(.navbar-right)')); + this.navbarAccount = this.navbar.element(by.css('#navbar-main .nav.navbar-nav.navbar-right')); + this.navbarAccountGreeting = this.navbarAccount.element(by.binding('getCurrentUser().name')); +}; + +module.exports = new NavbarComponent(); diff --git a/e2e/components/oauth-buttons/oauth-buttons.po.js b/e2e/components/oauth-buttons/oauth-buttons.po.js new file mode 100644 index 0000000..9cef0d4 --- /dev/null +++ b/e2e/components/oauth-buttons/oauth-buttons.po.js @@ -0,0 +1,14 @@ +/** + * This file uses the Page Object pattern to define the main page for tests + * https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ + */ + +'use strict'; + +var OauthButtons = function() { + var oauthButtons = this.oauthButtons = element(by.css('oauth-buttons')); + oauthButtons.facebook = oauthButtons.element(by.css('.btn.btn-social.btn-facebook')); + oauthButtons.google = oauthButtons.element(by.css('.btn.btn-social.btn-google')); +}; + +module.exports = new OauthButtons(); diff --git a/e2e/main/main.po.js b/e2e/main/main.po.js new file mode 100644 index 0000000..6718608 --- /dev/null +++ b/e2e/main/main.po.js @@ -0,0 +1,15 @@ +/** + * This file uses the Page Object pattern to define the main page for tests + * https://docs.google.com/presentation/d/1B6manhG0zEXkC-H-tPo2vwU06JhL8w9-XCF9oehXzAQ + */ + +'use strict'; + +var MainPage = function() { + this.heroEl = element(by.css('.hero-unit')); + this.h1El = this.heroEl.element(by.css('h1')); + this.imgEl = this.heroEl.element(by.css('img')); +}; + +module.exports = new MainPage(); + diff --git a/e2e/main/main.spec.js b/e2e/main/main.spec.js new file mode 100644 index 0000000..abfddf7 --- /dev/null +++ b/e2e/main/main.spec.js @@ -0,0 +1,19 @@ +'use strict'; + +var config = browser.params; + +describe('Main View', function() { + var page; + + beforeEach(function() { + let promise = browser.get(config.baseUrl + '/'); + page = require('./main.po'); + return promise; + }); + + it('should include jumbotron with correct data', function() { + expect(page.h1El.getText()).to.eventually.equal('\'Allo, \'Allo!'); + expect(page.imgEl.getAttribute('src')).to.eventually.match(/yeoman(\.[a-zA-Z0-9]*)?\.png$/); + expect(page.imgEl.getAttribute('alt')).to.eventually.equal('I\'m Yeoman'); + }); +}); diff --git a/gulpfile.babel.js b/gulpfile.babel.js new file mode 100644 index 0000000..b0b4440 --- /dev/null +++ b/gulpfile.babel.js @@ -0,0 +1,596 @@ +// Generated on 2017-06-04 using generator-angular-fullstack 4.2.2 +'use strict'; + +import _ from 'lodash'; +import del from 'del'; +import gulp from 'gulp'; +import grunt from 'grunt'; +import path from 'path'; +import through2 from 'through2'; +import gulpLoadPlugins from 'gulp-load-plugins'; +import http from 'http'; +import open from 'open'; +import lazypipe from 'lazypipe'; +import nodemon from 'nodemon'; +import {Server as KarmaServer} from 'karma'; +import runSequence from 'run-sequence'; +import {protractor, webdriver_update} from 'gulp-protractor'; +import {Instrumenter} from 'isparta'; +import webpack from 'webpack-stream'; +import makeWebpackConfig from './webpack.make'; + +var plugins = gulpLoadPlugins(); +var config; + +const clientPath = 'client'; +const serverPath = 'server'; +const paths = { + client: { + assets: `${clientPath}/assets/**/*`, + images: `${clientPath}/assets/images/**/*`, + revManifest: `${clientPath}/assets/rev-manifest.json`, + scripts: [ + `${clientPath}/**/!(*.spec|*.mock).js` + ], + styles: [`${clientPath}/{app,components}/**/*.css`], + mainStyle: `${clientPath}/app/app.css`, + views: `${clientPath}/{app,components}/**/*.html`, + mainView: `${clientPath}/index.html`, + test: [`${clientPath}/{app,components}/**/*.{spec,mock}.js`], + e2e: ['e2e/**/*.spec.js'] + }, + server: { + scripts: [ + `${serverPath}/**/!(*.spec|*.integration).js`, + `!${serverPath}/config/local.env.sample.js` + ], + json: [`${serverPath}/**/*.json`], + test: { + integration: [`${serverPath}/**/*.integration.js`, 'mocha.global.js'], + unit: [`${serverPath}/**/*.spec.js`, 'mocha.global.js'] + } + }, + karma: 'karma.conf.js', + dist: 'dist' +}; + +/******************** + * Helper functions + ********************/ + +function onServerLog(log) { + console.log(plugins.util.colors.white('[') + + plugins.util.colors.yellow('nodemon') + + plugins.util.colors.white('] ') + + log.message); +} + +function checkAppReady(cb) { + var options = { + host: 'localhost', + port: config.port + }; + http + .get(options, () => cb(true)) + .on('error', () => cb(false)); +} + +// Call page until first success +function whenServerReady(cb) { + var serverReady = false; + var appReadyInterval = setInterval(() => + checkAppReady((ready) => { + if (!ready || serverReady) { + return; + } + clearInterval(appReadyInterval); + serverReady = true; + cb(); + }), + 100); +} + +/******************** + * Reusable pipelines + ********************/ + +let lintClientScripts = lazypipe() + .pipe(plugins.eslint, `${clientPath}/.eslintrc`) + .pipe(plugins.eslint.format); + +const lintClientTestScripts = lazypipe() + .pipe(plugins.eslint, { + configFile: `${clientPath}/.eslintrc`, + envs: [ + 'browser', + 'es6', + 'mocha' + ] + }) + .pipe(plugins.eslint.format); + +let lintServerScripts = lazypipe() + .pipe(plugins.eslint, `${serverPath}/.eslintrc`) + .pipe(plugins.eslint.format); + +let lintServerTestScripts = lazypipe() + .pipe(plugins.eslint, { + configFile: `${serverPath}/.eslintrc`, + envs: [ + 'node', + 'es6', + 'mocha' + ] + }) + .pipe(plugins.eslint.format); + +let transpileServer = lazypipe() + .pipe(plugins.sourcemaps.init) + .pipe(plugins.babel, { + plugins: [ + 'transform-class-properties', + 'transform-runtime' + ] + }) + .pipe(plugins.sourcemaps.write, '.'); + +let mocha = lazypipe() + .pipe(plugins.mocha, { + reporter: 'spec', + timeout: 5000, + require: [ + './mocha.conf' + ] + }); + +let istanbul = lazypipe() + .pipe(plugins.istanbul.writeReports) + .pipe(plugins.istanbulEnforcer, { + thresholds: { + global: { + lines: 80, + statements: 80, + branches: 80, + functions: 80 + } + }, + coverageDirectory: './coverage', + rootDirectory : '' + }); + +/******************** + * Env + ********************/ + +gulp.task('env:all', () => { + let localConfig; + try { + localConfig = require(`./${serverPath}/config/local.env`); + } catch (e) { + localConfig = {}; + } + plugins.env({ + vars: localConfig + }); +}); +gulp.task('env:test', () => { + plugins.env({ + vars: {NODE_ENV: 'test'} + }); +}); +gulp.task('env:prod', () => { + plugins.env({ + vars: {NODE_ENV: 'production'} + }); +}); + +/******************** + * Tasks + ********************/ + +gulp.task('inject', cb => { + runSequence(['inject:css'], cb); +}); + +gulp.task('inject:css', () => { + return gulp.src(paths.client.mainStyle) + .pipe(plugins.inject( + gulp.src(_.union(paths.client.styles, ['!' + paths.client.mainStyle]), {read: false}) + .pipe(plugins.sort()), + { + starttag: '/* inject:css */', + endtag: '/* endinject */', + transform: (filepath) => { + let newPath = filepath + .replace(`/${clientPath}/app/`, '') + .replace(`/${clientPath}/components/`, '../components/') + .replace(/_(.*).css/, (match, p1, offset, string) => p1); + return `@import '${newPath}';`; + } + })) + .pipe(gulp.dest(`${clientPath}/app`)); +}); + +gulp.task('webpack:dev', function() { + const webpackDevConfig = makeWebpackConfig({ DEV: true }); + return gulp.src(webpackDevConfig.entry.app) + .pipe(plugins.plumber()) + .pipe(webpack(webpackDevConfig)) + .pipe(gulp.dest('.tmp')); +}); + +gulp.task('webpack:dist', function() { + const webpackDistConfig = makeWebpackConfig({ BUILD: true }); + return gulp.src(webpackDistConfig.entry.app) + .pipe(webpack(webpackDistConfig)) + .on('error', (err) => { + this.emit('end'); // Recover from errors + }) + .pipe(gulp.dest(`${paths.dist}/client`)); +}); + +gulp.task('webpack:test', function() { + const webpackTestConfig = makeWebpackConfig({ TEST: true }); + return gulp.src(webpackTestConfig.entry.app) + .pipe(webpack(webpackTestConfig)) + .pipe(gulp.dest('.tmp')); +}); + +gulp.task('webpack:e2e', function() { + const webpackE2eConfig = makeWebpackConfig({ E2E: true }); + return gulp.src(webpackE2eConfig.entry.app) + .pipe(webpack(webpackE2eConfig)) + .pipe(gulp.dest('.tmp')); +}); + +gulp.task('styles', () => { + return gulp.src(paths.client.styles) + .pipe(styles()) + .pipe(gulp.dest('.tmp/app')); +}); + +gulp.task('transpile:server', () => { + return gulp.src(_.union(paths.server.scripts, paths.server.json)) + .pipe(transpileServer()) + .pipe(gulp.dest(`${paths.dist}/${serverPath}`)); +}); + +gulp.task('lint:scripts', cb => runSequence(['lint:scripts:client', 'lint:scripts:server'], cb)); + +gulp.task('lint:scripts:client', () => { + return gulp.src(_.union( + paths.client.scripts, + _.map(paths.client.test, blob => '!' + blob) + )) + .pipe(lintClientScripts()); +}); + +gulp.task('lint:scripts:server', () => { + return gulp.src(_.union(paths.server.scripts, _.map(paths.server.test, blob => '!' + blob))) + .pipe(lintServerScripts()); +}); + +gulp.task('lint:scripts:clientTest', () => { + return gulp.src(paths.client.test) + .pipe(lintClientScripts()); +}); + +gulp.task('lint:scripts:serverTest', () => { + return gulp.src(paths.server.test) + .pipe(lintServerTestScripts()); +}); + +gulp.task('jscs', () => { + return gulp.src(_.union(paths.client.scripts, paths.server.scripts)) + .pipe(plugins.jscs()) + .pipe(plugins.jscs.reporter()); +}); + +gulp.task('clean:tmp', () => del(['.tmp/**/*'], {dot: true})); + +gulp.task('start:client', cb => { + whenServerReady(() => { + open('http://localhost:' + config.browserSyncPort); + cb(); + }); +}); + +gulp.task('start:server', () => { + process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + config = require(`./${serverPath}/config/environment`); + nodemon(`-w ${serverPath} ${serverPath}`) + .on('log', onServerLog); +}); + +gulp.task('start:server:prod', () => { + process.env.NODE_ENV = process.env.NODE_ENV || 'production'; + config = require(`./${paths.dist}/${serverPath}/config/environment`); + nodemon(`-w ${paths.dist}/${serverPath} ${paths.dist}/${serverPath}`) + .on('log', onServerLog); +}); + +gulp.task('start:server:debug', () => { + process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + config = require(`./${serverPath}/config/environment`); + // nodemon(`-w ${serverPath} --debug=5858 --debug-brk ${serverPath}`) + nodemon(`-w ${serverPath} --inspect --debug-brk ${serverPath}`) + .on('log', onServerLog); +}); + +gulp.task('watch', () => { + var testFiles = _.union(paths.client.test, paths.server.test.unit, paths.server.test.integration); + + plugins.watch(_.union(paths.server.scripts, testFiles)) + .pipe(plugins.plumber()) + .pipe(lintServerScripts()); + + plugins.watch(_.union(paths.server.test.unit, paths.server.test.integration)) + .pipe(plugins.plumber()) + .pipe(lintServerTestScripts()); +}); + +gulp.task('serve', cb => { + runSequence( + [ + 'clean:tmp', + 'lint:scripts', + 'inject', + 'copy:fonts:dev', + 'env:all' + ], + // 'webpack:dev', + ['start:server', 'start:client'], + 'watch', + cb + ); +}); + +gulp.task('serve:debug', cb => { + runSequence( + [ + 'clean:tmp', + 'lint:scripts', + 'inject', + 'copy:fonts:dev', + 'env:all' + ], + 'webpack:dev', + ['start:server:debug', 'start:client'], + 'watch', + cb + ); +}); + +gulp.task('serve:dist', cb => { + runSequence( + 'build', + 'env:all', + 'env:prod', + ['start:server:prod', 'start:client'], + cb); +}); + +gulp.task('test', cb => { + return runSequence('test:server', 'test:client', cb); +}); + +gulp.task('test:server', cb => { + runSequence( + 'env:all', + 'env:test', + 'mocha:unit', + 'mocha:integration', + cb); +}); + +gulp.task('mocha:unit', () => { + return gulp.src(paths.server.test.unit) + .pipe(mocha()); +}); + +gulp.task('mocha:integration', () => { + return gulp.src(paths.server.test.integration) + .pipe(mocha()); +}); + +gulp.task('test:server:coverage', cb => { + runSequence('coverage:pre', + 'env:all', + 'env:test', + 'coverage:unit', + 'coverage:integration', + cb); +}); + +gulp.task('coverage:pre', () => { + return gulp.src(paths.server.scripts) + // Covering files + .pipe(plugins.istanbul({ + instrumenter: Instrumenter, // Use the isparta instrumenter (code coverage for ES6) + includeUntested: true + })) + // Force `require` to return covered files + .pipe(plugins.istanbul.hookRequire()); +}); + +gulp.task('coverage:unit', () => { + return gulp.src(paths.server.test.unit) + .pipe(mocha()) + .pipe(istanbul()) + // Creating the reports after tests ran +}); + +gulp.task('coverage:integration', () => { + return gulp.src(paths.server.test.integration) + .pipe(mocha()) + .pipe(istanbul()) + // Creating the reports after tests ran +}); + +// Downloads the selenium webdriver +gulp.task('webdriver_update', webdriver_update); + +gulp.task('test:e2e', ['webpack:e2e', 'env:all', 'env:test', 'start:server', 'webdriver_update'], cb => { + gulp.src(paths.client.e2e) + .pipe(protractor({ + configFile: 'protractor.conf.js', + })) + .on('error', e => { throw e }) + .on('end', () => { process.exit() }); +}); + +gulp.task('test:client', done => { + new KarmaServer({ + configFile: `${__dirname}/${paths.karma}`, + singleRun: true + }, err => { + done(err); + process.exit(err); + }).start(); +}); + +/******************** + * Build + ********************/ + +gulp.task('build', cb => { + runSequence( + [ + 'clean:dist', + 'clean:tmp' + ], + 'inject', + 'transpile:server', + [ + 'build:images' + ], + [ + 'copy:extras', + 'copy:assets', + 'copy:fonts:dist', + 'copy:server', + 'webpack:dist' + ], + 'revReplaceWebpack', + cb); +}); + +gulp.task('clean:dist', () => del([`${paths.dist}/!(.git*|.openshift|Procfile)**`], {dot: true})); + +gulp.task('build:images', () => { + return gulp.src(paths.client.images) + .pipe(plugins.imagemin([ + plugins.imagemin.optipng({optimizationLevel: 5}), + plugins.imagemin.jpegtran({progressive: true}), + plugins.imagemin.gifsicle({interlaced: true}), + plugins.imagemin.svgo({plugins: [{removeViewBox: false}]}) + ])) + .pipe(plugins.rev()) + .pipe(gulp.dest(`${paths.dist}/${clientPath}/assets/images`)) + .pipe(plugins.rev.manifest(`${paths.dist}/${paths.client.revManifest}`, { + base: `${paths.dist}/${clientPath}/assets`, + merge: true + })) + .pipe(gulp.dest(`${paths.dist}/${clientPath}/assets`)); +}); + +gulp.task('revReplaceWebpack', function() { + return gulp.src('dist/client/app.*.js') + .pipe(plugins.revReplace({manifest: gulp.src(`${paths.dist}/${paths.client.revManifest}`)})) + .pipe(gulp.dest('dist/client')); +}); + +gulp.task('copy:extras', () => { + return gulp.src([ + `${clientPath}/favicon.ico`, + `${clientPath}/robots.txt`, + `${clientPath}/.htaccess` + ], { dot: true }) + .pipe(gulp.dest(`${paths.dist}/${clientPath}`)); +}); + +/** + * turns 'bootstrap/fonts/font.woff' into 'bootstrap/font.woff' + */ +function flatten() { + return through2.obj(function(file, enc, next) { + if(!file.isDirectory()) { + try { + let dir = path.dirname(file.relative).split(path.sep)[0]; + let fileName = path.normalize(path.basename(file.path)); + file.path = path.join(file.base, path.join(dir, fileName)); + this.push(file); + } catch(e) { + this.emit('error', new Error(e)); + } + } + next(); + }); +} +gulp.task('copy:fonts:dev', () => { + return gulp.src('node_modules/{bootstrap,font-awesome}/fonts/*') + .pipe(flatten()) + .pipe(gulp.dest(`${clientPath}/assets/fonts`)); +}); +gulp.task('copy:fonts:dist', () => { + return gulp.src('node_modules/{bootstrap,font-awesome}/fonts/*') + .pipe(flatten()) + .pipe(gulp.dest(`${paths.dist}/${clientPath}/assets/fonts`)); +}); + +gulp.task('copy:assets', () => { + return gulp.src([paths.client.assets, '!' + paths.client.images]) + .pipe(gulp.dest(`${paths.dist}/${clientPath}/assets`)); +}); + +gulp.task('copy:server', () => { + return gulp.src([ + 'package.json' + ], {cwdbase: true}) + .pipe(gulp.dest(paths.dist)); +}); + +/******************** + * Grunt ported tasks + ********************/ + +grunt.initConfig({ + buildcontrol: { + options: { + dir: paths.dist, + commit: true, + push: true, + connectCommits: false, + message: 'Built %sourceName% from commit %sourceCommit% on branch %sourceBranch%' + }, + heroku: { + options: { + remote: 'heroku', + branch: 'master' + } + }, + openshift: { + options: { + remote: 'openshift', + branch: 'master' + } + } + } +}); + +grunt.loadNpmTasks('grunt-build-control'); + +gulp.task('buildcontrol:heroku', function(done) { + grunt.tasks( + ['buildcontrol:heroku'], //you can add more grunt tasks in this array + {gruntfile: false}, //don't look for a Gruntfile - there is none. :-) + function() {done();} + ); +}); +gulp.task('buildcontrol:openshift', function(done) { + grunt.tasks( + ['buildcontrol:openshift'], //you can add more grunt tasks in this array + {gruntfile: false}, //don't look for a Gruntfile - there is none. :-) + function() {done();} + ); +}); diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..d0e5eb6 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,98 @@ +// Karma configuration +// http://karma-runner.github.io/0.13/config/configuration-file.html +/*eslint-env node*/ + +import makeWebpackConfig from './webpack.make'; + +module.exports = function(config) { + config.set({ + // base path, that will be used to resolve files and exclude + basePath: '', + + // testing framework to use (jasmine/mocha/qunit/...) + frameworks: ['mocha', 'chai', 'sinon-chai', 'chai-as-promised', 'chai-things'], + + client: { + mocha: { + timeout: 5000 // set default mocha spec timeout + } + }, + + // list of files / patterns to load in the browser + files: ['spec.js'], + + preprocessors: { + 'spec.js': ['webpack'] + }, + + webpack: makeWebpackConfig({ TEST: true }), + + webpackMiddleware: { + // webpack-dev-middleware configuration + // i. e. + noInfo: true + }, + + coverageReporter: { + reporters: [{ + type: 'html', //produces a html document after code is run + subdir: 'client' + }, { + type: 'json', + subdir: '.', + file: 'client-coverage.json' + }], + dir: 'coverage/' //path to created html doc + }, + + plugins: [ + require('karma-chrome-launcher'), + require('karma-coverage'), + require('karma-firefox-launcher'), + require('karma-mocha'), + require('karma-chai-plugins'), + + require('karma-spec-reporter'), + require('karma-phantomjs-launcher'), + require('karma-script-launcher'), + require('karma-webpack'), + require('karma-sourcemap-loader') + ], + + // list of files / patterns to exclude + exclude: [], + + // web server port + port: 9000, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + logLevel: config.LOG_INFO, + + // reporter types: + // - dots + // - progress (default) + // - spec (karma-spec-reporter) + // - junit + // - growl + // - coverage + reporters: ['spec', 'coverage'], + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: ['PhantomJS'], + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false + }); +}; diff --git a/mocha.conf.js b/mocha.conf.js new file mode 100644 index 0000000..76f5662 --- /dev/null +++ b/mocha.conf.js @@ -0,0 +1,19 @@ +'use strict'; + +// Register the Babel require hook +require('babel-core/register'); + +var chai = require('chai'); + +// Load Chai assertions +global.expect = chai.expect; +global.assert = chai.assert; +chai.should(); + +// Load Sinon +global.sinon = require('sinon'); + +// Initialize Chai plugins +chai.use(require('sinon-chai')); +chai.use(require('chai-as-promised')); +chai.use(require('chai-things')) diff --git a/mocha.global.js b/mocha.global.js new file mode 100644 index 0000000..e7ebef8 --- /dev/null +++ b/mocha.global.js @@ -0,0 +1,8 @@ +import app from './'; +import mongoose from 'mongoose'; + +after(function(done) { + app.angularFullstack.on('close', () => done()); + mongoose.connection.close(); + app.angularFullstack.close(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..477a02a --- /dev/null +++ b/package.json @@ -0,0 +1,151 @@ +{ + "name": "app2", + "version": "0.0.0", + "main": "server/index.js", + "dependencies": { + "ace-builds": "^1.2.6", + "angular": "~1.6.0", + "angular-animate": "~1.6.0", + "angular-aria": "~1.6.0", + "angular-cookies": "~1.6.0", + "angular-markdown-directive": "^0.3.1", + "angular-resource": "~1.6.0", + "angular-sanitize": "^1.6.4", + "angular-tree-control": "^0.2.28", + "angular-ui-ace": "^0.2.3", + "angular-ui-bootstrap": "^2.0.1", + "angular-ui-router": "~0.3.1", + "angular-validation-match": "^1.9.0", + "ansi-to-html": "^0.6.2", + "ansi2html": "0.0.1", + "babel-polyfill": "^6.7.2", + "babel-runtime": "^6.6.1", + "bluebird": "^3.3.3", + "body-parser": "^1.13.3", + "bootstrap": "~3.3.7", + "bootstrap-social": "^5.0.0", + "composable-middleware": "^0.3.0", + "connect-mongo": "^1.2.1", + "cookie-parser": "^1.3.5", + "core-js": "^2.2.1", + "ejs": "^2.5.3", + "errorhandler": "^1.4.2", + "express": "^4.13.3", + "express-jwt": "^5.0.0", + "express-session": "^1.11.3", + "fast-json-patch": "^1.0.0", + "filendir": "^1.0.0", + "font-awesome": ">=4.1.0", + "json-loader": "^0.5.4", + "jsonwebtoken": "^7.0.0", + "lodash": "^4.6.1", + "lusca": "^1.3.0", + "method-override": "^2.3.5", + "mongoose": "^4.1.2", + "morgan": "^1.8.0", + "passport": "~0.3.0", + "passport-facebook": "^2.0.0", + "passport-google-oauth20": "^1.0.0", + "passport-local": "^1.0.0", + "scp2": "^0.5.0", + "serve-favicon": "^2.3.0", + "showdown": "^0.3.1", + "shrink-ray": "^0.1.3", + "sprint-js": "~0.1.0", + "ssh2": "^0.5.0", + "underscore": "^1.8.3", + "yamljs": "^0.2.10" + }, + "devDependencies": { + "angular-mocks": "~1.6.0", + "autoprefixer": "^6.0.0", + "babel-core": "^6.6.5", + "babel-eslint": "^6.0.4", + "babel-register": "^6.6.5", + "browser-sync": "^2.8.0", + "bs-fullscreen-message": "^1.0.0", + "babel-plugin-transform-class-properties": "^6.6.0", + "babel-plugin-transform-runtime": "^6.6.0", + "babel-preset-es2015": "^6.6.0", + "eslint": "^2.12.0", + "del": "^2.0.2", + "gulp": "^3.9.1", + "gulp-babel": "^6.1.2", + "gulp-env": "^0.4.0", + "gulp-eslint": "^2.0.0", + "gulp-imagemin": "^3.0.1", + "gulp-inject": "^4.0.0", + "gulp-istanbul": "^1.1.1", + "gulp-istanbul-enforcer": "^1.0.3", + "gulp-load-plugins": "^1.0.0-rc.1", + "gulp-mocha": "^2.1.3", + "gulp-plumber": "^1.0.1", + "gulp-protractor": "^3.0.0", + "gulp-rev": "^7.0.0", + "gulp-rev-replace": "^0.4.2", + "gulp-sort": "^2.0.0", + "gulp-sourcemaps": "^1.5.2", + "gulp-util": "^3.0.5", + "gulp-watch": "^4.3.5", + "gulp-stylint": "^3.0.0", + "grunt": "^1.0.1", + "grunt-build-control": "^0.7.0", + "isparta": "^4.0.0", + "nodemon": "^1.3.7", + "run-sequence": "^1.1.0", + "lazypipe": "^1.0.1", + "webpack": "^1.12.14", + "webpack-dev-middleware": "^1.5.1", + "webpack-stream": "^3.2.0", + "extract-text-webpack-plugin": "^1.0.1", + "html-webpack-plugin": "^2.16.0", + "html-webpack-harddisk-plugin": "~0.0.2", + "awesome-typescript-loader": "^1.1.1", + "ng-annotate-loader": "~0.1.0", + "babel-loader": "^6.2.4", + "css-loader": "^0.24.0", + "file-loader": "^0.9.0", + "imports-loader": "^0.6.5", + "isparta-instrumenter-loader": "^1.0.0", + "isparta-loader": "^2.0.0", + "istanbul-instrumenter-loader": "^0.2.0", + "null-loader": "^0.1.1", + "postcss-loader": "^0.11.1", + "raw-loader": "^0.5.1", + "style-loader": "^0.13.0", + "karma-webpack": "^1.7.0", + "through2": "^2.0.1", + "open": "~0.0.4", + "istanbul": "1.1.0-alpha.1", + "chai": "^3.2.0", + "sinon": "^1.16.1", + "chai-as-promised": "^5.1.0", + "chai-things": "^0.2.0", + "karma": "~0.13.3", + "karma-firefox-launcher": "^1.0.0", + "karma-script-launcher": "^1.0.0", + "karma-coverage": "^1.0.0", + "karma-chrome-launcher": "^2.0.0", + "karma-phantomjs-launcher": "~1.0.0", + "karma-spec-reporter": "~0.0.20", + "karma-sourcemap-loader": "~0.3.7", + "sinon-chai": "^2.8.0", + "mocha": "^3.0.2", + "karma-mocha": "^1.0.1", + "karma-chai-plugins": "~0.7.0", + "phantomjs-prebuilt": "^2.1.4", + "proxyquire": "^1.0.1", + "strip-ansi": "^3.0.1", + "supertest": "^1.1.0" + }, + "engines": { + "node": "^6.2.2", + "npm": "^3.9.5" + }, + "scripts": { + "test": "gulp test", + "update-webdriver": "node node_modules/protractor/bin/webdriver-manager update", + "start": "node server" + }, + "private": true +} diff --git a/protractor.conf.js b/protractor.conf.js new file mode 100644 index 0000000..a9669d9 --- /dev/null +++ b/protractor.conf.js @@ -0,0 +1,81 @@ +// Protractor configuration +// https://github.com/angular/protractor/blob/master/referenceConf.js + +'use strict'; + +var config = { + // The timeout for each script run on the browser. This should be longer + // than the maximum time your application needs to stabilize between tasks. + allScriptsTimeout: 110000, + + // A base URL for your application under test. Calls to protractor.get() + // with relative paths will be prepended with this. + baseUrl: 'http://localhost:' + (process.env.PORT || '9000'), + + // Credientials for Saucelabs + sauceUser: process.env.SAUCE_USERNAME, + + sauceKey: process.env.SAUCE_ACCESS_KEY, + + // list of files / patterns to load in the browser + specs: [ + 'e2e/**/*.spec.js' + ], + + // Patterns to exclude. + exclude: [], + + // ----- Capabilities to be passed to the webdriver instance ---- + // + // For a full list of available capabilities, see + // https://code.google.com/p/selenium/wiki/DesiredCapabilities + // and + // https://code.google.com/p/selenium/source/browse/javascript/webdriver/capabilities.js + capabilities: { + 'browserName': 'chrome', + 'name': 'Fullstack E2E', + 'tunnel-identifier': process.env.TRAVIS_JOB_NUMBER, + 'build': process.env.TRAVIS_BUILD_NUMBER + }, + + // ----- The test framework ----- + // + // Jasmine and Cucumber are fully supported as a test and assertion framework. + // Mocha has limited beta support. You will need to include your own + // assertion framework if working with mocha. + framework: 'mocha', + + // ----- Options to be passed to mocha ----- + mochaOpts: { + reporter: 'spec', + timeout: 30000, + defaultTimeoutInterval: 30000 + }, + + // Prepare environment for tests + params: { + serverConfig: require('./server/config/environment') + }, + + onPrepare: function() { + require('babel-register'); + // Load Mocha and Chai + plugins + require('./mocha.conf'); + + // Expose should assertions (see https://github.com/angular/protractor/issues/633) + Object.defineProperty( + protractor.promise.Promise.prototype, + 'should', + Object.getOwnPropertyDescriptor(Object.prototype, 'should') + ); + + var serverConfig = config.params.serverConfig; + + // Setup mongo for tests + var mongoose = require('mongoose'); + mongoose.connect(serverConfig.mongo.uri, serverConfig.mongo.options); // Connect to database + } +}; + +config.params.baseUrl = config.baseUrl; +exports.config = config; diff --git a/server/.eslintrc b/server/.eslintrc new file mode 100644 index 0000000..849296d --- /dev/null +++ b/server/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../.eslintrc", + "env": { + "node": true + } +} diff --git a/server/api/ansible/ansible.controller.js b/server/api/ansible/ansible.controller.js new file mode 100644 index 0000000..074a00b --- /dev/null +++ b/server/api/ansible/ansible.controller.js @@ -0,0 +1,814 @@ +/** + * Using Rails-like standard naming convention for endpoints. + * GET /api/ansible -> index + * POST /api/ansible -> create + * GET /api/ansible/:id -> show + * PUT /api/ansible/:id -> upsert + * PATCH /api/ansible/:id -> patch + * DELETE /api/ansible/:id -> destroy + */ + +'use strict'; + +import jsonpatch from 'fast-json-patch'; +import Ansible from './ansible.model'; +var ssh2_exec = require('../../components/ssh/ssh2_exec'); +var ansibleTool = require('../../components/ansible/ansible_tool'); + + +function respondWithResult(res, statusCode) { + statusCode = statusCode || 200; + return function(entity) { + if(entity) { + return res.status(statusCode).json(entity); + } + return null; + }; +} + +function patchUpdates(patches) { + return function(entity) { + try { + // eslint-disable-next-line prefer-reflect + jsonpatch.apply(entity, patches, /*validate*/ true); + } catch(err) { + return Promise.reject(err); + } + + return entity.save(); + }; +} + +function removeEntity(res) { + return function(entity) { + if(entity) { + return entity.remove() + .then(() => { + res.status(204).end(); + }); + } + }; +} + +function handleEntityNotFound(res) { + return function(entity) { + if(!entity) { + res.status(404).end(); + return null; + } + return entity; + }; +} + +function handleError(res, statusCode) { + statusCode = statusCode || 500; + return function(err) { + res.status(statusCode).send(err); + }; +} + +// Creates a new Ansible in the DB +export function command(req, res) { + + var command = req.body.command; + + var ansibleEngine = req.body.ansibleEngine; + + ssh2_exec.executeCommand(command, + null, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ) +} + +// Creates a new Ansible in the DB +export function modules(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + + ansibleTool.getModules(function(data){ + res.write(data) + }, + function(data){ + res.write(data); + res.end() + }, + function(data){ + res.write(data) + }, + ansibleEngine + ); + +} + +// Gets a single Deploy from the DB +export function getLogs(req, res) { + return Ansible.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(function(entity){ + console.log("Getting logs " + entity.logfile); + ansibleTool.getLogs(entity.logfile, + function(successData){ + return res.send(successData); + }, + function(errorData){ + return res.status(500).send(errorData) + } + ); + return null; + }) + .catch(handleError(res)); +} + +// Executes Ansible Play book in the backend +export function execute(req, res) { + + //var inventory_file_contents = req.body.inventory_file_contents; + //var playbook_file_contents = req.body.playbook_file_contents; + + var playbook_name = req.body.selectedPlaybook; + var inventory_file_name = req.body.inventory_file_name; + + var tags = req.body.tags; + var limit_to_hosts = req.body.limit_to_hosts; + var verbose = req.body.verbose; + var check_mode = req.body.check_mode; + + var ansibleEngine = req.body.ansibleEngine; + var project_folder = ansibleEngine.projectFolder; + + console.log("Check_Mode=" + check_mode); + + var time = new Date().getTime(); + var logfilename = 'execution_' + time; + + var tags_joined = tags; + if(typeof tags === 'object')tags_joined = tags.join(','); + + var limit_to_hosts_joined = limit_to_hosts; + if(typeof limit_to_hosts === 'object')limit_to_hosts_joined = limit_to_hosts.join(','); + + var ansibleObject = { + logfile: logfilename, + tags: tags_joined, + limit_to_hosts: limit_to_hosts, + verbose: verbose, + host: req.body.host, + check_mode: check_mode, + selectedPlaybook: req.body.selectedPlaybook, + selectedPlay: req.body.selectedPlay, + executionType: req.body.executionType, + executionName: req.body.executionName, + executionTime: time + }; + + var resultSent = false; + + ansibleTool.executeAnsible(logfilename, project_folder, playbook_name, inventory_file_name, tags_joined, limit_to_hosts_joined, verbose,check_mode, + function(data){ + //res.write(data) + if(!resultSent){ + resultSent = true; + return Ansible.create(ansibleObject) + .then(respondWithResult(res, 201)) + .catch(handleError(res)); + } + }, + function(data){ + //res.write(data); + //res.end() + if(!resultSent){ + resultSent = true; + return Ansible.create(ansibleObject) + .then(respondWithResult(res, 201)) + .catch(handleError(res)); + } + }, + function(data){ + //res.write(data) + if(!resultSent){ + resultSent = true; + res.status(500).send(data) + } + }, + ansibleEngine + ); + +} + + +/** + * List playbook tags + * ansible-playbook playbook.yml -i inventory --list-tags + * @param req + * @param res + */ +export function playbook_tags_list(req, res) { + + var playbook_name = req.body.selectedPlaybook; + var inventory_file_name = req.body.inventory_file_name; + + var ansibleEngine = req.body.ansibleEngine; + var project_folder = ansibleEngine.projectFolder; + + ansibleTool.getTagList(project_folder, playbook_name, inventory_file_name, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + + +export function playbook_create(req, res) { + + var playbook_file_contents = req.body.playbookFileContents; + var ansibleEngine = req.body.ansibleEngine; + var play_book_name = req.body.playbookName; + var project_folder = ansibleEngine.projectFolder; + + play_book_name = play_book_name.replace(project_folder,''); + console.log("Playbook name = " + play_book_name); + + var resultSent = false; + ansibleTool.writePlaybook(project_folder,play_book_name,playbook_file_contents, + function(data){ + //res.write(data); + //res.end() + if(!resultSent){ + resultSent = true; + res.send(data) + } + }, + function(data){ + //res.write(data) + if(!resultSent){ + resultSent = true; + res.status(500).send(data) + } + }, + ansibleEngine + ); + +} + +export function playbook_delete(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var play_book_name = req.body.playbookName; + var project_folder = ansibleEngine.projectFolder; + + var resultSent = false; + ansibleTool.deletePlaybook(project_folder,play_book_name, + function(data){ + res.write(data) + }, + function(data){ + if(!resultSent){ + resultSent = true; + res.write(data); + res.end(); + } + }, + function(data){ + if(!resultSent){ + resultSent = true; + res.status(500); + res.write(data); + res.end(); + } + }, + ansibleEngine + ); + +} + +export function playbook_get(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var play_book_name = req.body.playbookName; + var project_folder = ansibleEngine.projectFolder; + + var resultSent = false; + ansibleTool.readPlaybook(project_folder,play_book_name, + function(data){ + res.write(data) + }, + function(data){ + if(!resultSent){ + resultSent = true; + res.write(data); + res.end(); + } + }, + function(data){ + if(!resultSent){ + resultSent = true; + res.status(500); + res.write(data); + res.end(); + } + }, + ansibleEngine + ); + +} + + +export function playbook_list(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var project_folder = ansibleEngine.projectFolder; + + ansibleTool.getPlaybookList(project_folder, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + + +export function roles_list(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var project_folder = ansibleEngine.projectFolder; + + ansibleTool.getRolesList(project_folder, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function inventory_list(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var project_folder = ansibleEngine.projectFolder; + + ansibleTool.getInventoryList(project_folder, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function inventory_get(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var project_folder = ansibleEngine.projectFolder; + var inventoryName = req.body.inventoryName; + + ansibleTool.readInventoryFile(project_folder,inventoryName, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + + +export function inventory_create(req, res) { + + var inventoryFileContents = req.body.inventoryFileContents; + var ansibleEngine = req.body.ansibleEngine; + var inventoryName = req.body.inventoryName; + var project_folder = ansibleEngine.projectFolder; + + var file_path = project_folder + '/' + inventoryName; + + ansibleTool.writeFile(file_path,inventoryFileContents, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + +} + +export function inventory_delete(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var inventoryName = req.body.inventoryName; + var project_folder = ansibleEngine.projectFolder; + + var file_path = project_folder + '/' + inventoryName; + + ansibleTool.deleteFile(file_path, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + +} + +export function update_groups_vars_file(req, res) { + + var groupVarsContents = req.body.groupVarsContents; + var ansibleEngine = req.body.ansibleEngine; + var groupName = req.body.groupName; + var project_folder = ansibleEngine.projectFolder; + + var file_path = project_folder + '/group_vars/' + groupName; + + ansibleTool.writeFile(file_path, groupVarsContents, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + +} + +export function get_groups_vars_file(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var groupName = req.body.groupName; + var project_folder = ansibleEngine.projectFolder; + + var file_path = project_folder + '/group_vars/' + groupName; + + ansibleTool.readFile(file_path, + null, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + +} + + +export function update_hosts_vars_file(req, res) { + + var hostVarsContents = req.body.hostVarsContents; + var ansibleEngine = req.body.ansibleEngine; + var hostName = req.body.hostName; + var project_folder = ansibleEngine.projectFolder; + + var file_path = project_folder + '/host_vars/' + hostName; + + ansibleTool.writeFile(file_path, hostVarsContents, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + +} + +export function get_hosts_vars_file(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var hostName = req.body.hostName; + var project_folder = ansibleEngine.projectFolder; + + var file_path = project_folder + '/host_vars/' + hostName; + + ansibleTool.readFile(file_path, + null, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + +} + + +/** + * Get variables for a host using Python AnsibleAPI + * @param req + * @param res + */ +export function get_hosts_vars(req,res){ + + var ansibleEngine = req.body.ansibleEngine; + var host_name = req.body.hostName; + var project_folder = ansibleEngine.projectFolder; + var inventory_file_name = req.body.inventoryFileName; + + console.log('hostName=' + host_name) + + ansibleTool.getVars(project_folder,inventory_file_name,host_name, + null, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine) + +} + + +/** + * Get variables for a role using Python AnsibleAPI + * @param req + * @param res + */ +export function get_roles_vars(req,res){ + + var ansibleEngine = req.body.ansibleEngine; + var role_name = req.body.roleName; + var project_folder = ansibleEngine.projectFolder; + + console.log('roleName=' + role_name); + + ansibleTool.getRolesVars(project_folder,role_name, + null, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + + }, + ansibleEngine) + +} + +export function roles_search_galaxy(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var searchText = req.body.searchText; + + ansibleTool.searchRolesGalaxy(searchText, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function roles_search_github(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var searchText = req.body.searchText; + + ansibleTool.searchRolesGithub(searchText, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +/** + * Create/Copy Role + * Create a new role if selectedRoleName is null + * Copy existing role if selectedRoleName is not null + * @param req + * @param res + */ +export function roles_create(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var roleName = req.body.roleName; + var selectedRoleName = req.body.selectedRoleName; + + var createRoleFunction = ansibleTool.createRole; + + if(selectedRoleName) + createRoleFunction = ansibleTool.copyRole; + + createRoleFunction(roleName, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine, + selectedRoleName + ); + +} + +export function roles_import(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var roleType = req.body.roleType; + var roleNameUri = req.body.roleNameUri; + + ansibleTool.importRole(roleType,roleNameUri, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function roles_delete(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var roleName = req.body.roleName; + + ansibleTool.deleteRole(roleName, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function roles_files(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var roleName = req.body.roleName; + + ansibleTool.getRoleFiles(roleName, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function project_files(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + + ansibleTool.getProjectFiles( + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function file_create(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var fileAbsolutePath = req.body.fileAbsolutePath; + + ansibleTool.createFile(fileAbsolutePath, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function file_update(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var fileAbsolutePath = req.body.fileAbsolutePath; + var fileContents = req.body.fileContents; + + ansibleTool.writeFile(fileAbsolutePath,fileContents, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +export function file_delete(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + var fileAbsolutePath = req.body.fileAbsolutePath; + + ansibleTool.deleteFile(fileAbsolutePath, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data); + }, + ansibleEngine + ); + +} + +// Gets a list of Ansibles +export function index(req, res) { + return Ansible.find().exec() + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Gets a single Ansible from the DB +export function show(req, res) { + return Ansible.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Creates a new Ansible in the DB +export function create(req, res) { + return Ansible.create(req.body) + .then(respondWithResult(res, 201)) + .catch(handleError(res)); +} + +// Upserts the given Ansible in the DB at the specified ID +export function upsert(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return Ansible.findOneAndUpdate({_id: req.params.id}, req.body, {new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true}).exec() + + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Updates an existing Ansible in the DB +export function patch(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return Ansible.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(patchUpdates(req.body)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Deletes a Ansible from the DB +export function destroy(req, res) { + return Ansible.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(removeEntity(res)) + .catch(handleError(res)); +} diff --git a/server/api/ansible/ansible.events.js b/server/api/ansible/ansible.events.js new file mode 100644 index 0000000..5337401 --- /dev/null +++ b/server/api/ansible/ansible.events.js @@ -0,0 +1,35 @@ +/** + * Ansible model events + */ + +'use strict'; + +import {EventEmitter} from 'events'; +var AnsibleEvents = new EventEmitter(); + +// Set max event listeners (0 == unlimited) +AnsibleEvents.setMaxListeners(0); + +// Model events +var events = { + save: 'save', + remove: 'remove' +}; + +// Register the event emitter to the model events +function registerEvents(Ansible) { + for(var e in events) { + let event = events[e]; + Ansible.post(e, emitEvent(event)); + } +} + +function emitEvent(event) { + return function(doc) { + AnsibleEvents.emit(event + ':' + doc._id, doc); + AnsibleEvents.emit(event, doc); + }; +} + +export {registerEvents}; +export default AnsibleEvents; diff --git a/server/api/ansible/ansible.integration.js b/server/api/ansible/ansible.integration.js new file mode 100644 index 0000000..71105f6 --- /dev/null +++ b/server/api/ansible/ansible.integration.js @@ -0,0 +1,190 @@ +'use strict'; + +/* globals describe, expect, it, beforeEach, afterEach */ + +var app = require('../..'); +import request from 'supertest'; + +var newAnsible; + +describe('Ansible API:', function() { + describe('GET /api/ansible', function() { + var ansibles; + + beforeEach(function(done) { + request(app) + .get('/api/ansible') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + ansibles = res.body; + done(); + }); + }); + + it('should respond with JSON array', function() { + expect(ansibles).to.be.instanceOf(Array); + }); + }); + + describe('POST /api/ansible', function() { + beforeEach(function(done) { + request(app) + .post('/api/ansible') + .send({ + name: 'New Ansible', + info: 'This is the brand new ansible!!!' + }) + .expect(201) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + newAnsible = res.body; + done(); + }); + }); + + it('should respond with the newly created ansible', function() { + expect(newAnsible.name).to.equal('New Ansible'); + expect(newAnsible.info).to.equal('This is the brand new ansible!!!'); + }); + }); + + describe('GET /api/ansible/:id', function() { + var ansible; + + beforeEach(function(done) { + request(app) + .get(`/api/ansible/${newAnsible._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + ansible = res.body; + done(); + }); + }); + + afterEach(function() { + ansible = {}; + }); + + it('should respond with the requested ansible', function() { + expect(ansible.name).to.equal('New Ansible'); + expect(ansible.info).to.equal('This is the brand new ansible!!!'); + }); + }); + + describe('PUT /api/ansible/:id', function() { + var updatedAnsible; + + beforeEach(function(done) { + request(app) + .put(`/api/ansible/${newAnsible._id}`) + .send({ + name: 'Updated Ansible', + info: 'This is the updated ansible!!!' + }) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + updatedAnsible = res.body; + done(); + }); + }); + + afterEach(function() { + updatedAnsible = {}; + }); + + it('should respond with the updated ansible', function() { + expect(updatedAnsible.name).to.equal('Updated Ansible'); + expect(updatedAnsible.info).to.equal('This is the updated ansible!!!'); + }); + + it('should respond with the updated ansible on a subsequent GET', function(done) { + request(app) + .get(`/api/ansible/${newAnsible._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + let ansible = res.body; + + expect(ansible.name).to.equal('Updated Ansible'); + expect(ansible.info).to.equal('This is the updated ansible!!!'); + + done(); + }); + }); + }); + + describe('PATCH /api/ansible/:id', function() { + var patchedAnsible; + + beforeEach(function(done) { + request(app) + .patch(`/api/ansible/${newAnsible._id}`) + .send([ + { op: 'replace', path: '/name', value: 'Patched Ansible' }, + { op: 'replace', path: '/info', value: 'This is the patched ansible!!!' } + ]) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + patchedAnsible = res.body; + done(); + }); + }); + + afterEach(function() { + patchedAnsible = {}; + }); + + it('should respond with the patched ansible', function() { + expect(patchedAnsible.name).to.equal('Patched Ansible'); + expect(patchedAnsible.info).to.equal('This is the patched ansible!!!'); + }); + }); + + describe('DELETE /api/ansible/:id', function() { + it('should respond with 204 on successful removal', function(done) { + request(app) + .delete(`/api/ansible/${newAnsible._id}`) + .expect(204) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + + it('should respond with 404 when ansible does not exist', function(done) { + request(app) + .delete(`/api/ansible/${newAnsible._id}`) + .expect(404) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + }); +}); diff --git a/server/api/ansible/ansible.model.js b/server/api/ansible/ansible.model.js new file mode 100644 index 0000000..1d0c82d --- /dev/null +++ b/server/api/ansible/ansible.model.js @@ -0,0 +1,24 @@ +'use strict'; + +import mongoose from 'mongoose'; +import {registerEvents} from './ansible.events'; + +var AnsibleSchema = new mongoose.Schema({ + name: String, + info: String, + active: Boolean, + logfile: String, + tags: String, + limit_to_hosts: String, + host: String, + verbose: String, + check_mode: Boolean, + selectedPlaybook: String, + selectedPlay: String, + executionType: String, + executionName: String, + executionTime: Date +}); + +registerEvents(AnsibleSchema); +export default mongoose.model('Ansible', AnsibleSchema); diff --git a/server/api/ansible/index.js b/server/api/ansible/index.js new file mode 100644 index 0000000..bf06fc3 --- /dev/null +++ b/server/api/ansible/index.js @@ -0,0 +1,59 @@ +'use strict'; + +var express = require('express'); +var controller = require('./ansible.controller'); + +var router = express.Router(); + +router.get('/', controller.index); + +router.post('/modules', controller.modules); +router.post('/command', controller.command); +router.post('/execute', controller.execute); + +router.post('/project/files', controller.project_files); + +router.post('/playbook/get', controller.playbook_get); +router.post('/playbook/create', controller.playbook_create); +router.post('/playbook/delete', controller.playbook_delete); +router.post('/playbook/list', controller.playbook_list); + + +router.post('/roles/create', controller.roles_create); +router.post('/roles/list', controller.roles_list); +router.post('/roles/search/galaxy', controller.roles_search_galaxy); +router.post('/roles/search/github', controller.roles_search_github); +router.post('/roles/delete', controller.roles_delete); +router.post('/roles/files', controller.roles_files); +router.post('/roles/import', controller.roles_import); + +router.post('/tags/list', controller.playbook_tags_list); + +router.post('/files/create', controller.file_create); +router.post('/files/update', controller.file_update); +router.post('/files/delete', controller.file_delete); + + +router.post('/inventory/list', controller.inventory_list); +router.post('/inventory/get', controller.inventory_get); +router.post('/inventory/create', controller.inventory_create); +router.post('/inventory/delete', controller.inventory_delete); + +router.post('/vars_file/groups/update', controller.update_groups_vars_file); +router.post('/vars_file/groups/get', controller.get_groups_vars_file); + +router.post('/vars_file/hosts/update', controller.update_hosts_vars_file); +router.post('/vars_file/hosts/get', controller.get_hosts_vars_file); + +router.post('/vars/hosts/get', controller.get_hosts_vars); +router.post('/vars/roles/get', controller.get_roles_vars); + +router.get('/logs/:id', controller.getLogs); + +router.get('/:id', controller.show); +router.post('/', controller.create); +router.put('/:id', controller.upsert); +router.patch('/:id', controller.patch); +router.delete('/:id', controller.destroy); + +module.exports = router; diff --git a/server/api/ansible/index.spec.js b/server/api/ansible/index.spec.js new file mode 100644 index 0000000..86817fc --- /dev/null +++ b/server/api/ansible/index.spec.js @@ -0,0 +1,86 @@ +'use strict'; + +/* globals sinon, describe, expect, it */ + +var proxyquire = require('proxyquire').noPreserveCache(); + +var ansibleCtrlStub = { + index: 'ansibleCtrl.index', + show: 'ansibleCtrl.show', + create: 'ansibleCtrl.create', + upsert: 'ansibleCtrl.upsert', + patch: 'ansibleCtrl.patch', + destroy: 'ansibleCtrl.destroy' +}; + +var routerStub = { + get: sinon.spy(), + put: sinon.spy(), + patch: sinon.spy(), + post: sinon.spy(), + delete: sinon.spy() +}; + +// require the index with our stubbed out modules +var ansibleIndex = proxyquire('./index.js', { + express: { + Router() { + return routerStub; + } + }, + './ansible.controller': ansibleCtrlStub +}); + +describe('Ansible API Router:', function() { + it('should return an express router instance', function() { + expect(ansibleIndex).to.equal(routerStub); + }); + + describe('GET /api/ansible', function() { + it('should route to ansible.controller.index', function() { + expect(routerStub.get + .withArgs('/', 'ansibleCtrl.index') + ).to.have.been.calledOnce; + }); + }); + + describe('GET /api/ansible/:id', function() { + it('should route to ansible.controller.show', function() { + expect(routerStub.get + .withArgs('/:id', 'ansibleCtrl.show') + ).to.have.been.calledOnce; + }); + }); + + describe('POST /api/ansible', function() { + it('should route to ansible.controller.create', function() { + expect(routerStub.post + .withArgs('/', 'ansibleCtrl.create') + ).to.have.been.calledOnce; + }); + }); + + describe('PUT /api/ansible/:id', function() { + it('should route to ansible.controller.upsert', function() { + expect(routerStub.put + .withArgs('/:id', 'ansibleCtrl.upsert') + ).to.have.been.calledOnce; + }); + }); + + describe('PATCH /api/ansible/:id', function() { + it('should route to ansible.controller.patch', function() { + expect(routerStub.patch + .withArgs('/:id', 'ansibleCtrl.patch') + ).to.have.been.calledOnce; + }); + }); + + describe('DELETE /api/ansible/:id', function() { + it('should route to ansible.controller.destroy', function() { + expect(routerStub.delete + .withArgs('/:id', 'ansibleCtrl.destroy') + ).to.have.been.calledOnce; + }); + }); +}); diff --git a/server/api/custom_module/custom_module.controller.js b/server/api/custom_module/custom_module.controller.js new file mode 100644 index 0000000..d4019f9 --- /dev/null +++ b/server/api/custom_module/custom_module.controller.js @@ -0,0 +1,220 @@ +/** + * Using Rails-like standard naming convention for endpoints. + * GET /api/custom_modules -> index + * POST /api/custom_modules -> create + * GET /api/custom_modules/:id -> show + * PUT /api/custom_modules/:id -> upsert + * PATCH /api/custom_modules/:id -> patch + * DELETE /api/custom_modules/:id -> destroy + */ + +'use strict'; + +import jsonpatch from 'fast-json-patch'; +import CustomModule from './custom_module.model'; +var ssh2_exec = require('../../components/ssh/ssh2_exec'); +var scp2_exec = require('../../components/scp/scp_exec'); + +function respondWithResult(res, statusCode) { + statusCode = statusCode || 200; + return function(entity) { + if(entity) { + return res.status(statusCode).json(entity); + } + return null; + }; +} + +function patchUpdates(patches) { + return function(entity) { + try { + // eslint-disable-next-line prefer-reflect + jsonpatch.apply(entity, patches, /*validate*/ true); + } catch(err) { + return Promise.reject(err); + } + + return entity.save(); + }; +} + +function removeEntity(res) { + return function(entity) { + if(entity) { + return entity.remove() + .then(() => { + res.status(204).end(); + }); + } + }; +} + +function handleEntityNotFound(res) { + return function(entity) { + if(!entity) { + res.status(404).end(); + return null; + } + return entity; + }; +} + +function handleError(res, statusCode) { + statusCode = statusCode || 500; + return function(err) { + res.status(statusCode).send(err); + }; +} + +// Gets a list of CustomModules +export function index(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + + if(!ansibleEngine.customModules){ + return res.status(500).send("Custom Modules Folder not defined in Ansible Engine") + } + + var command = 'ls "' + ansibleEngine.customModules + '"'; + + ssh2_exec.executeCommand(command, + null, + function(data){ + res.send(data) + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + + /*return CustomModule.find().exec() + .then(respondWithResult(res)) + .catch(handleError(res));*/ +} + +// Gets a single CustomModule from the DB +export function show(req, res) { + console.log("Show " + req.params.custom_module); + var ansibleEngine = req.body.ansibleEngine; + + if(!ansibleEngine.customModules){ + res.status(500).send("Custom Modules Folder not defined in Ansible Engine") + } + + var command = 'cat "' + ansibleEngine.customModules + '"/' + req.params.custom_module; + + if(req.params.custom_module === 'template.py'){ + command = 'cat ' + '/opt/ehc-builder-scripts/ansible_modules/template.py'; + } + + + ssh2_exec.executeCommand(command, + null, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + + /*return CustomModule.findById(req.params.custom_module).exec() + .then(handleEntityNotFound(res)) + .then(respondWithResult(res)) + .catch(handleError(res));*/ +} + +// Test Module +export function testModule(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + + var moduleArgs = req.body.moduleArgs; + + if(!ansibleEngine.customModules){ + res.status(500).send("Custom Modules Folder not defined in Ansible Engine") + } + + var command = '/opt/ansible/ansible-devel/hacking/test-module -m "' + ansibleEngine.customModules + '/' + req.params.custom_module + "\" -a '" + JSON.stringify(moduleArgs) + "'"; + + console.log("Command=" + command); + + ssh2_exec.executeCommand(command, + null, + function(data){ + res.send(data); + }, + function(data){ + res.status(500).send(data) + }, + ansibleEngine + ); + + /*return CustomModule.findById(req.params.custom_module).exec() + .then(handleEntityNotFound(res)) + .then(respondWithResult(res)) + .catch(handleError(res));*/ +} + +// Creates a new CustomModule in the DB +export function create(req, res) { + + console.log("Create"); + + var custom_module_name = req.params.custom_module; + var custom_module_code = req.body.custom_module_code; + + var ansibleEngine = req.body.ansibleEngine; + + if(!ansibleEngine.customModules){ + res.status(500).send("Custom Modules Folder not defined in Ansible Engine") + } + + console.log("Custom module name " + "\"" + ansibleEngine.customModules + '/' + custom_module_name + "\"") + + scp2_exec.createFileOnScriptEngine(custom_module_code,ansibleEngine.customModules + '/' + custom_module_name, + function(){ + res.send("Saved") + },function(err){ + res.status(500).send("Failed to create file on target") + }, + ansibleEngine + ); + + /*return CustomModule.create(req.body) + .then(respondWithResult(res, 201)) + .catch(handleError(res));*/ +} + +// Upserts the given CustomModule in the DB at the specified ID +export function upsert(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return CustomModule.findOneAndUpdate({_id: req.params.id}, req.body, {new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true}).exec() + + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Updates an existing CustomModule in the DB +export function patch(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return CustomModule.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(patchUpdates(req.body)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Deletes a CustomModule from the DB +export function destroy(req, res) { + return CustomModule.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(removeEntity(res)) + .catch(handleError(res)); +} diff --git a/server/api/custom_module/custom_module.events.js b/server/api/custom_module/custom_module.events.js new file mode 100644 index 0000000..dda994b --- /dev/null +++ b/server/api/custom_module/custom_module.events.js @@ -0,0 +1,35 @@ +/** + * CustomModule model events + */ + +'use strict'; + +import {EventEmitter} from 'events'; +var CustomModuleEvents = new EventEmitter(); + +// Set max event listeners (0 == unlimited) +CustomModuleEvents.setMaxListeners(0); + +// Model events +var events = { + save: 'save', + remove: 'remove' +}; + +// Register the event emitter to the model events +function registerEvents(CustomModule) { + for(var e in events) { + let event = events[e]; + CustomModule.post(e, emitEvent(event)); + } +} + +function emitEvent(event) { + return function(doc) { + CustomModuleEvents.emit(event + ':' + doc._id, doc); + CustomModuleEvents.emit(event, doc); + }; +} + +export {registerEvents}; +export default CustomModuleEvents; diff --git a/server/api/custom_module/custom_module.integration.js b/server/api/custom_module/custom_module.integration.js new file mode 100644 index 0000000..5454c26 --- /dev/null +++ b/server/api/custom_module/custom_module.integration.js @@ -0,0 +1,190 @@ +'use strict'; + +/* globals describe, expect, it, beforeEach, afterEach */ + +var app = require('../..'); +import request from 'supertest'; + +var newCustomModule; + +describe('CustomModule API:', function() { + describe('GET /api/custom_modules', function() { + var customModules; + + beforeEach(function(done) { + request(app) + .get('/api/custom_modules') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + customModules = res.body; + done(); + }); + }); + + it('should respond with JSON array', function() { + expect(customModules).to.be.instanceOf(Array); + }); + }); + + describe('POST /api/custom_modules', function() { + beforeEach(function(done) { + request(app) + .post('/api/custom_modules') + .send({ + name: 'New CustomModule', + info: 'This is the brand new customModule!!!' + }) + .expect(201) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + newCustomModule = res.body; + done(); + }); + }); + + it('should respond with the newly created customModule', function() { + expect(newCustomModule.name).to.equal('New CustomModule'); + expect(newCustomModule.info).to.equal('This is the brand new customModule!!!'); + }); + }); + + describe('GET /api/custom_modules/:id', function() { + var customModule; + + beforeEach(function(done) { + request(app) + .get(`/api/custom_modules/${newCustomModule._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + customModule = res.body; + done(); + }); + }); + + afterEach(function() { + customModule = {}; + }); + + it('should respond with the requested customModule', function() { + expect(customModule.name).to.equal('New CustomModule'); + expect(customModule.info).to.equal('This is the brand new customModule!!!'); + }); + }); + + describe('PUT /api/custom_modules/:id', function() { + var updatedCustomModule; + + beforeEach(function(done) { + request(app) + .put(`/api/custom_modules/${newCustomModule._id}`) + .send({ + name: 'Updated CustomModule', + info: 'This is the updated customModule!!!' + }) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + updatedCustomModule = res.body; + done(); + }); + }); + + afterEach(function() { + updatedCustomModule = {}; + }); + + it('should respond with the updated customModule', function() { + expect(updatedCustomModule.name).to.equal('Updated CustomModule'); + expect(updatedCustomModule.info).to.equal('This is the updated customModule!!!'); + }); + + it('should respond with the updated customModule on a subsequent GET', function(done) { + request(app) + .get(`/api/custom_modules/${newCustomModule._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + let customModule = res.body; + + expect(customModule.name).to.equal('Updated CustomModule'); + expect(customModule.info).to.equal('This is the updated customModule!!!'); + + done(); + }); + }); + }); + + describe('PATCH /api/custom_modules/:id', function() { + var patchedCustomModule; + + beforeEach(function(done) { + request(app) + .patch(`/api/custom_modules/${newCustomModule._id}`) + .send([ + { op: 'replace', path: '/name', value: 'Patched CustomModule' }, + { op: 'replace', path: '/info', value: 'This is the patched customModule!!!' } + ]) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + patchedCustomModule = res.body; + done(); + }); + }); + + afterEach(function() { + patchedCustomModule = {}; + }); + + it('should respond with the patched customModule', function() { + expect(patchedCustomModule.name).to.equal('Patched CustomModule'); + expect(patchedCustomModule.info).to.equal('This is the patched customModule!!!'); + }); + }); + + describe('DELETE /api/custom_modules/:id', function() { + it('should respond with 204 on successful removal', function(done) { + request(app) + .delete(`/api/custom_modules/${newCustomModule._id}`) + .expect(204) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + + it('should respond with 404 when customModule does not exist', function(done) { + request(app) + .delete(`/api/custom_modules/${newCustomModule._id}`) + .expect(404) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + }); +}); diff --git a/server/api/custom_module/custom_module.model.js b/server/api/custom_module/custom_module.model.js new file mode 100644 index 0000000..1f18055 --- /dev/null +++ b/server/api/custom_module/custom_module.model.js @@ -0,0 +1,13 @@ +'use strict'; + +import mongoose from 'mongoose'; +import {registerEvents} from './custom_module.events'; + +var CustomModuleSchema = new mongoose.Schema({ + name: String, + info: String, + active: Boolean +}); + +registerEvents(CustomModuleSchema); +export default mongoose.model('CustomModule', CustomModuleSchema); diff --git a/server/api/custom_module/index.js b/server/api/custom_module/index.js new file mode 100644 index 0000000..6bdc2d1 --- /dev/null +++ b/server/api/custom_module/index.js @@ -0,0 +1,16 @@ +'use strict'; + +var express = require('express'); +var controller = require('./custom_module.controller'); + +var router = express.Router(); + +router.post('/query', controller.index); +router.post('/:custom_module/test', controller.testModule); +router.post('/:custom_module/get', controller.show); +router.post('/:custom_module', controller.create); +router.put('/:id', controller.upsert); +router.patch('/:id', controller.patch); +router.delete('/:id', controller.destroy); + +module.exports = router; diff --git a/server/api/custom_module/index.spec.js b/server/api/custom_module/index.spec.js new file mode 100644 index 0000000..04b1873 --- /dev/null +++ b/server/api/custom_module/index.spec.js @@ -0,0 +1,86 @@ +'use strict'; + +/* globals sinon, describe, expect, it */ + +var proxyquire = require('proxyquire').noPreserveCache(); + +var customModuleCtrlStub = { + index: 'customModuleCtrl.index', + show: 'customModuleCtrl.show', + create: 'customModuleCtrl.create', + upsert: 'customModuleCtrl.upsert', + patch: 'customModuleCtrl.patch', + destroy: 'customModuleCtrl.destroy' +}; + +var routerStub = { + get: sinon.spy(), + put: sinon.spy(), + patch: sinon.spy(), + post: sinon.spy(), + delete: sinon.spy() +}; + +// require the index with our stubbed out modules +var customModuleIndex = proxyquire('./index.js', { + express: { + Router() { + return routerStub; + } + }, + './custom_module.controller': customModuleCtrlStub +}); + +describe('CustomModule API Router:', function() { + it('should return an express router instance', function() { + expect(customModuleIndex).to.equal(routerStub); + }); + + describe('GET /api/custom_modules', function() { + it('should route to customModule.controller.index', function() { + expect(routerStub.get + .withArgs('/', 'customModuleCtrl.index') + ).to.have.been.calledOnce; + }); + }); + + describe('GET /api/custom_modules/:id', function() { + it('should route to customModule.controller.show', function() { + expect(routerStub.get + .withArgs('/:id', 'customModuleCtrl.show') + ).to.have.been.calledOnce; + }); + }); + + describe('POST /api/custom_modules', function() { + it('should route to customModule.controller.create', function() { + expect(routerStub.post + .withArgs('/', 'customModuleCtrl.create') + ).to.have.been.calledOnce; + }); + }); + + describe('PUT /api/custom_modules/:id', function() { + it('should route to customModule.controller.upsert', function() { + expect(routerStub.put + .withArgs('/:id', 'customModuleCtrl.upsert') + ).to.have.been.calledOnce; + }); + }); + + describe('PATCH /api/custom_modules/:id', function() { + it('should route to customModule.controller.patch', function() { + expect(routerStub.patch + .withArgs('/:id', 'customModuleCtrl.patch') + ).to.have.been.calledOnce; + }); + }); + + describe('DELETE /api/custom_modules/:id', function() { + it('should route to customModule.controller.destroy', function() { + expect(routerStub.delete + .withArgs('/:id', 'customModuleCtrl.destroy') + ).to.have.been.calledOnce; + }); + }); +}); diff --git a/server/api/project/index.js b/server/api/project/index.js new file mode 100644 index 0000000..bd6a8bc --- /dev/null +++ b/server/api/project/index.js @@ -0,0 +1,15 @@ +'use strict'; + +var express = require('express'); +var controller = require('./project.controller'); + +var router = express.Router(); + +router.get('/', controller.index); +router.get('/:id', controller.show); +router.post('/', controller.create); +router.put('/:id', controller.upsert); +router.patch('/:id', controller.patch); +router.delete('/:id', controller.destroy); + +module.exports = router; diff --git a/server/api/project/index.spec.js b/server/api/project/index.spec.js new file mode 100644 index 0000000..6801df5 --- /dev/null +++ b/server/api/project/index.spec.js @@ -0,0 +1,86 @@ +'use strict'; + +/* globals sinon, describe, expect, it */ + +var proxyquire = require('proxyquire').noPreserveCache(); + +var projectCtrlStub = { + index: 'projectCtrl.index', + show: 'projectCtrl.show', + create: 'projectCtrl.create', + upsert: 'projectCtrl.upsert', + patch: 'projectCtrl.patch', + destroy: 'projectCtrl.destroy' +}; + +var routerStub = { + get: sinon.spy(), + put: sinon.spy(), + patch: sinon.spy(), + post: sinon.spy(), + delete: sinon.spy() +}; + +// require the index with our stubbed out modules +var projectIndex = proxyquire('./index.js', { + express: { + Router() { + return routerStub; + } + }, + './project.controller': projectCtrlStub +}); + +describe('Project API Router:', function() { + it('should return an express router instance', function() { + expect(projectIndex).to.equal(routerStub); + }); + + describe('GET /api/projects', function() { + it('should route to project.controller.index', function() { + expect(routerStub.get + .withArgs('/', 'projectCtrl.index') + ).to.have.been.calledOnce; + }); + }); + + describe('GET /api/projects/:id', function() { + it('should route to project.controller.show', function() { + expect(routerStub.get + .withArgs('/:id', 'projectCtrl.show') + ).to.have.been.calledOnce; + }); + }); + + describe('POST /api/projects', function() { + it('should route to project.controller.create', function() { + expect(routerStub.post + .withArgs('/', 'projectCtrl.create') + ).to.have.been.calledOnce; + }); + }); + + describe('PUT /api/projects/:id', function() { + it('should route to project.controller.upsert', function() { + expect(routerStub.put + .withArgs('/:id', 'projectCtrl.upsert') + ).to.have.been.calledOnce; + }); + }); + + describe('PATCH /api/projects/:id', function() { + it('should route to project.controller.patch', function() { + expect(routerStub.patch + .withArgs('/:id', 'projectCtrl.patch') + ).to.have.been.calledOnce; + }); + }); + + describe('DELETE /api/projects/:id', function() { + it('should route to project.controller.destroy', function() { + expect(routerStub.delete + .withArgs('/:id', 'projectCtrl.destroy') + ).to.have.been.calledOnce; + }); + }); +}); diff --git a/server/api/project/project.controller.js b/server/api/project/project.controller.js new file mode 100644 index 0000000..45f3eaa --- /dev/null +++ b/server/api/project/project.controller.js @@ -0,0 +1,152 @@ +/** + * Using Rails-like standard naming convention for endpoints. + * GET /api/projects -> index + * POST /api/projects -> create + * GET /api/projects/:id -> show + * PUT /api/projects/:id -> upsert + * PATCH /api/projects/:id -> patch + * DELETE /api/projects/:id -> destroy + */ + +'use strict'; + +import jsonpatch from 'fast-json-patch'; +import Project from './project.model'; +var ansibleTool = require('../../components/ansible/ansible_tool'); + +function respondWithResult(res, statusCode) { + statusCode = statusCode || 200; + return function(entity) { + if(entity) { + return res.status(statusCode).json(entity); + } + return null; + }; +} + +function patchUpdates(patches) { + return function(entity) { + try { + // eslint-disable-next-line prefer-reflect + jsonpatch.apply(entity, patches, /*validate*/ true); + } catch(err) { + return Promise.reject(err); + } + + return entity.save(); + }; +} + +function removeEntity(res) { + return function(entity) { + if(entity) { + return entity.remove() + .then(() => { + res.status(204).end(); + }); + } + }; +} + +function handleEntityNotFound(res) { + return function(entity) { + if(!entity) { + res.status(404).end(); + return null; + } + return entity; + }; +} + +function handleError(res, statusCode) { + statusCode = statusCode || 500; + return function(err) { + res.status(statusCode).send(err); + }; +} + +// Gets a list of Projects +export function index(req, res) { + console.log("Getting projects list"); + return Project.find().exec() + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Gets a single Project from the DB +export function show(req, res) { + return Project.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + + +// Creates a new Project in the DB +export function create(req, res) { + + var ansibleEngine = req.body.ansibleEngine; + + console.log("Ansible Engine " + JSON.stringify(ansibleEngine)); + + if(ansibleEngine.ansibleHost){ + ansibleTool.getAnsibleVersion( + function(version){ + + req.body.ansibleVersion = version; + + ansibleTool.createProjectFolder(ansibleEngine.projectFolder, + function(){ + return Project.create(req.body) + .then(respondWithResult(res, 201)) + .catch(handleError(res)); + }, + function(data){ + res.status(500).send(data) + }, ansibleEngine); + + //res.write(data); + //res.end() + }, + function(data){ + res.status(500).send("" + data); + },ansibleEngine + ) + }else{ + return Project.create(req.body) + .then(respondWithResult(res, 201)) + .catch(handleError(res)); + } + +} + +// Upserts the given Project in the DB at the specified ID +export function upsert(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return Project.findOneAndUpdate({_id: req.params.id}, req.body, {new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true}).exec() + + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Updates an existing Project in the DB +export function patch(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return Project.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(patchUpdates(req.body)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Deletes a Project from the DB +export function destroy(req, res) { + return Project.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(removeEntity(res)) + .catch(handleError(res)); +} diff --git a/server/api/project/project.events.js b/server/api/project/project.events.js new file mode 100644 index 0000000..376ed52 --- /dev/null +++ b/server/api/project/project.events.js @@ -0,0 +1,35 @@ +/** + * Project model events + */ + +'use strict'; + +import {EventEmitter} from 'events'; +var ProjectEvents = new EventEmitter(); + +// Set max event listeners (0 == unlimited) +ProjectEvents.setMaxListeners(0); + +// Model events +var events = { + save: 'save', + remove: 'remove' +}; + +// Register the event emitter to the model events +function registerEvents(Project) { + for(var e in events) { + let event = events[e]; + Project.post(e, emitEvent(event)); + } +} + +function emitEvent(event) { + return function(doc) { + ProjectEvents.emit(event + ':' + doc._id, doc); + ProjectEvents.emit(event, doc); + }; +} + +export {registerEvents}; +export default ProjectEvents; diff --git a/server/api/project/project.integration.js b/server/api/project/project.integration.js new file mode 100644 index 0000000..5616f23 --- /dev/null +++ b/server/api/project/project.integration.js @@ -0,0 +1,190 @@ +'use strict'; + +/* globals describe, expect, it, beforeEach, afterEach */ + +var app = require('../..'); +import request from 'supertest'; + +var newProject; + +describe('Project API:', function() { + describe('GET /api/projects', function() { + var projects; + + beforeEach(function(done) { + request(app) + .get('/api/projects') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + projects = res.body; + done(); + }); + }); + + it('should respond with JSON array', function() { + expect(projects).to.be.instanceOf(Array); + }); + }); + + describe('POST /api/projects', function() { + beforeEach(function(done) { + request(app) + .post('/api/projects') + .send({ + name: 'New Project', + info: 'This is the brand new project!!!' + }) + .expect(201) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + newProject = res.body; + done(); + }); + }); + + it('should respond with the newly created project', function() { + expect(newProject.name).to.equal('New Project'); + expect(newProject.info).to.equal('This is the brand new project!!!'); + }); + }); + + describe('GET /api/projects/:id', function() { + var project; + + beforeEach(function(done) { + request(app) + .get(`/api/projects/${newProject._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + project = res.body; + done(); + }); + }); + + afterEach(function() { + project = {}; + }); + + it('should respond with the requested project', function() { + expect(project.name).to.equal('New Project'); + expect(project.info).to.equal('This is the brand new project!!!'); + }); + }); + + describe('PUT /api/projects/:id', function() { + var updatedProject; + + beforeEach(function(done) { + request(app) + .put(`/api/projects/${newProject._id}`) + .send({ + name: 'Updated Project', + info: 'This is the updated project!!!' + }) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + updatedProject = res.body; + done(); + }); + }); + + afterEach(function() { + updatedProject = {}; + }); + + it('should respond with the updated project', function() { + expect(updatedProject.name).to.equal('Updated Project'); + expect(updatedProject.info).to.equal('This is the updated project!!!'); + }); + + it('should respond with the updated project on a subsequent GET', function(done) { + request(app) + .get(`/api/projects/${newProject._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + let project = res.body; + + expect(project.name).to.equal('Updated Project'); + expect(project.info).to.equal('This is the updated project!!!'); + + done(); + }); + }); + }); + + describe('PATCH /api/projects/:id', function() { + var patchedProject; + + beforeEach(function(done) { + request(app) + .patch(`/api/projects/${newProject._id}`) + .send([ + { op: 'replace', path: '/name', value: 'Patched Project' }, + { op: 'replace', path: '/info', value: 'This is the patched project!!!' } + ]) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + patchedProject = res.body; + done(); + }); + }); + + afterEach(function() { + patchedProject = {}; + }); + + it('should respond with the patched project', function() { + expect(patchedProject.name).to.equal('Patched Project'); + expect(patchedProject.info).to.equal('This is the patched project!!!'); + }); + }); + + describe('DELETE /api/projects/:id', function() { + it('should respond with 204 on successful removal', function(done) { + request(app) + .delete(`/api/projects/${newProject._id}`) + .expect(204) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + + it('should respond with 404 when project does not exist', function(done) { + request(app) + .delete(`/api/projects/${newProject._id}`) + .expect(404) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + }); +}); diff --git a/server/api/project/project.model.js b/server/api/project/project.model.js new file mode 100644 index 0000000..e629e48 --- /dev/null +++ b/server/api/project/project.model.js @@ -0,0 +1,22 @@ +'use strict'; + +import mongoose from 'mongoose'; +import {registerEvents} from './project.events'; + +var ProjectSchema = new mongoose.Schema({ + name: String, + ansibleEngine: {}, + ansibleVersion : String, + creationTime: Date, + info: String, + active: Boolean, + ansible_data: String, //YAML Format + ansible_data_json: {}, //JSON Format + inventory_data: String, //YAML Format + inventory_data_json: {}, //JSON Format + roles_data: String, //YAML Format + roles_data_json: {} //JSON Format +}); + +registerEvents(ProjectSchema); +export default mongoose.model('Project', ProjectSchema); diff --git a/server/api/thing/index.js b/server/api/thing/index.js new file mode 100644 index 0000000..bd1da07 --- /dev/null +++ b/server/api/thing/index.js @@ -0,0 +1,15 @@ +'use strict'; + +var express = require('express'); +var controller = require('./thing.controller'); + +var router = express.Router(); + +router.get('/', controller.index); +router.get('/:id', controller.show); +router.post('/', controller.create); +router.put('/:id', controller.upsert); +router.patch('/:id', controller.patch); +router.delete('/:id', controller.destroy); + +module.exports = router; diff --git a/server/api/thing/index.spec.js b/server/api/thing/index.spec.js new file mode 100644 index 0000000..af463fa --- /dev/null +++ b/server/api/thing/index.spec.js @@ -0,0 +1,86 @@ +'use strict'; + +/* globals sinon, describe, expect, it */ + +var proxyquire = require('proxyquire').noPreserveCache(); + +var thingCtrlStub = { + index: 'thingCtrl.index', + show: 'thingCtrl.show', + create: 'thingCtrl.create', + upsert: 'thingCtrl.upsert', + patch: 'thingCtrl.patch', + destroy: 'thingCtrl.destroy' +}; + +var routerStub = { + get: sinon.spy(), + put: sinon.spy(), + patch: sinon.spy(), + post: sinon.spy(), + delete: sinon.spy() +}; + +// require the index with our stubbed out modules +var thingIndex = proxyquire('./index.js', { + express: { + Router() { + return routerStub; + } + }, + './thing.controller': thingCtrlStub +}); + +describe('Thing API Router:', function() { + it('should return an express router instance', function() { + expect(thingIndex).to.equal(routerStub); + }); + + describe('GET /api/things', function() { + it('should route to thing.controller.index', function() { + expect(routerStub.get + .withArgs('/', 'thingCtrl.index') + ).to.have.been.calledOnce; + }); + }); + + describe('GET /api/things/:id', function() { + it('should route to thing.controller.show', function() { + expect(routerStub.get + .withArgs('/:id', 'thingCtrl.show') + ).to.have.been.calledOnce; + }); + }); + + describe('POST /api/things', function() { + it('should route to thing.controller.create', function() { + expect(routerStub.post + .withArgs('/', 'thingCtrl.create') + ).to.have.been.calledOnce; + }); + }); + + describe('PUT /api/things/:id', function() { + it('should route to thing.controller.upsert', function() { + expect(routerStub.put + .withArgs('/:id', 'thingCtrl.upsert') + ).to.have.been.calledOnce; + }); + }); + + describe('PATCH /api/things/:id', function() { + it('should route to thing.controller.patch', function() { + expect(routerStub.patch + .withArgs('/:id', 'thingCtrl.patch') + ).to.have.been.calledOnce; + }); + }); + + describe('DELETE /api/things/:id', function() { + it('should route to thing.controller.destroy', function() { + expect(routerStub.delete + .withArgs('/:id', 'thingCtrl.destroy') + ).to.have.been.calledOnce; + }); + }); +}); diff --git a/server/api/thing/thing.controller.js b/server/api/thing/thing.controller.js new file mode 100644 index 0000000..f8ddd9c --- /dev/null +++ b/server/api/thing/thing.controller.js @@ -0,0 +1,118 @@ +/** + * Using Rails-like standard naming convention for endpoints. + * GET /api/things -> index + * POST /api/things -> create + * GET /api/things/:id -> show + * PUT /api/things/:id -> upsert + * PATCH /api/things/:id -> patch + * DELETE /api/things/:id -> destroy + */ + +'use strict'; + +import jsonpatch from 'fast-json-patch'; +import Thing from './thing.model'; + +function respondWithResult(res, statusCode) { + statusCode = statusCode || 200; + return function(entity) { + if(entity) { + return res.status(statusCode).json(entity); + } + return null; + }; +} + +function patchUpdates(patches) { + return function(entity) { + try { + // eslint-disable-next-line prefer-reflect + jsonpatch.apply(entity, patches, /*validate*/ true); + } catch(err) { + return Promise.reject(err); + } + + return entity.save(); + }; +} + +function removeEntity(res) { + return function(entity) { + if(entity) { + return entity.remove() + .then(() => { + res.status(204).end(); + }); + } + }; +} + +function handleEntityNotFound(res) { + return function(entity) { + if(!entity) { + res.status(404).end(); + return null; + } + return entity; + }; +} + +function handleError(res, statusCode) { + statusCode = statusCode || 500; + return function(err) { + res.status(statusCode).send(err); + }; +} + +// Gets a list of Things +export function index(req, res) { + return Thing.find().exec() + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Gets a single Thing from the DB +export function show(req, res) { + return Thing.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Creates a new Thing in the DB +export function create(req, res) { + return Thing.create(req.body) + .then(respondWithResult(res, 201)) + .catch(handleError(res)); +} + +// Upserts the given Thing in the DB at the specified ID +export function upsert(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return Thing.findOneAndUpdate({_id: req.params.id}, req.body, {new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true}).exec() + + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Updates an existing Thing in the DB +export function patch(req, res) { + if(req.body._id) { + Reflect.deleteProperty(req.body, '_id'); + } + return Thing.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(patchUpdates(req.body)) + .then(respondWithResult(res)) + .catch(handleError(res)); +} + +// Deletes a Thing from the DB +export function destroy(req, res) { + return Thing.findById(req.params.id).exec() + .then(handleEntityNotFound(res)) + .then(removeEntity(res)) + .catch(handleError(res)); +} diff --git a/server/api/thing/thing.events.js b/server/api/thing/thing.events.js new file mode 100644 index 0000000..3add968 --- /dev/null +++ b/server/api/thing/thing.events.js @@ -0,0 +1,35 @@ +/** + * Thing model events + */ + +'use strict'; + +import {EventEmitter} from 'events'; +var ThingEvents = new EventEmitter(); + +// Set max event listeners (0 == unlimited) +ThingEvents.setMaxListeners(0); + +// Model events +var events = { + save: 'save', + remove: 'remove' +}; + +// Register the event emitter to the model events +function registerEvents(Thing) { + for(var e in events) { + let event = events[e]; + Thing.post(e, emitEvent(event)); + } +} + +function emitEvent(event) { + return function(doc) { + ThingEvents.emit(`${event}:${doc._id}`, doc); + ThingEvents.emit(event, doc); + }; +} + +export {registerEvents}; +export default ThingEvents; diff --git a/server/api/thing/thing.integration.js b/server/api/thing/thing.integration.js new file mode 100644 index 0000000..85e0d16 --- /dev/null +++ b/server/api/thing/thing.integration.js @@ -0,0 +1,190 @@ +'use strict'; + +/* globals describe, expect, it, beforeEach, afterEach */ + +var app = require('../..'); +import request from 'supertest'; + +var newThing; + +describe('Thing API:', function() { + describe('GET /api/things', function() { + var things; + + beforeEach(function(done) { + request(app) + .get('/api/things') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + things = res.body; + done(); + }); + }); + + it('should respond with JSON array', function() { + expect(things).to.be.instanceOf(Array); + }); + }); + + describe('POST /api/things', function() { + beforeEach(function(done) { + request(app) + .post('/api/things') + .send({ + name: 'New Thing', + info: 'This is the brand new thing!!!' + }) + .expect(201) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + newThing = res.body; + done(); + }); + }); + + it('should respond with the newly created thing', function() { + expect(newThing.name).to.equal('New Thing'); + expect(newThing.info).to.equal('This is the brand new thing!!!'); + }); + }); + + describe('GET /api/things/:id', function() { + var thing; + + beforeEach(function(done) { + request(app) + .get(`/api/things/${newThing._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + thing = res.body; + done(); + }); + }); + + afterEach(function() { + thing = {}; + }); + + it('should respond with the requested thing', function() { + expect(thing.name).to.equal('New Thing'); + expect(thing.info).to.equal('This is the brand new thing!!!'); + }); + }); + + describe('PUT /api/things/:id', function() { + var updatedThing; + + beforeEach(function(done) { + request(app) + .put(`/api/things/${newThing._id}`) + .send({ + name: 'Updated Thing', + info: 'This is the updated thing!!!' + }) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + updatedThing = res.body; + done(); + }); + }); + + afterEach(function() { + updatedThing = {}; + }); + + it('should respond with the updated thing', function() { + expect(updatedThing.name).to.equal('Updated Thing'); + expect(updatedThing.info).to.equal('This is the updated thing!!!'); + }); + + it('should respond with the updated thing on a subsequent GET', function(done) { + request(app) + .get(`/api/things/${newThing._id}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if(err) { + return done(err); + } + let thing = res.body; + + expect(thing.name).to.equal('Updated Thing'); + expect(thing.info).to.equal('This is the updated thing!!!'); + + done(); + }); + }); + }); + + describe('PATCH /api/things/:id', function() { + var patchedThing; + + beforeEach(function(done) { + request(app) + .patch(`/api/things/${newThing._id}`) + .send([ + { op: 'replace', path: '/name', value: 'Patched Thing' }, + { op: 'replace', path: '/info', value: 'This is the patched thing!!!' } + ]) + .expect(200) + .expect('Content-Type', /json/) + .end(function(err, res) { + if(err) { + return done(err); + } + patchedThing = res.body; + done(); + }); + }); + + afterEach(function() { + patchedThing = {}; + }); + + it('should respond with the patched thing', function() { + expect(patchedThing.name).to.equal('Patched Thing'); + expect(patchedThing.info).to.equal('This is the patched thing!!!'); + }); + }); + + describe('DELETE /api/things/:id', function() { + it('should respond with 204 on successful removal', function(done) { + request(app) + .delete(`/api/things/${newThing._id}`) + .expect(204) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + + it('should respond with 404 when thing does not exist', function(done) { + request(app) + .delete(`/api/things/${newThing._id}`) + .expect(404) + .end(err => { + if(err) { + return done(err); + } + done(); + }); + }); + }); +}); diff --git a/server/api/thing/thing.model.js b/server/api/thing/thing.model.js new file mode 100644 index 0000000..061a817 --- /dev/null +++ b/server/api/thing/thing.model.js @@ -0,0 +1,13 @@ +'use strict'; + +import mongoose from 'mongoose'; +import {registerEvents} from './thing.events'; + +var ThingSchema = new mongoose.Schema({ + name: String, + info: String, + active: Boolean +}); + +registerEvents(ThingSchema); +export default mongoose.model('Thing', ThingSchema); diff --git a/server/api/user/index.js b/server/api/user/index.js new file mode 100644 index 0000000..fad00f6 --- /dev/null +++ b/server/api/user/index.js @@ -0,0 +1,16 @@ +'use strict'; + +import {Router} from 'express'; +import * as controller from './user.controller'; +import * as auth from '../../auth/auth.service'; + +var router = new Router(); + +router.get('/', auth.hasRole('admin'), controller.index); +router.delete('/:id', auth.hasRole('admin'), controller.destroy); +router.get('/me', auth.isAuthenticated(), controller.me); +router.put('/:id/password', auth.isAuthenticated(), controller.changePassword); +router.get('/:id', auth.isAuthenticated(), controller.show); +router.post('/', controller.create); + +module.exports = router; diff --git a/server/api/user/index.spec.js b/server/api/user/index.spec.js new file mode 100644 index 0000000..773a546 --- /dev/null +++ b/server/api/user/index.spec.js @@ -0,0 +1,95 @@ +'use strict'; + +/* globals sinon, describe, expect, it */ + +var proxyquire = require('proxyquire').noPreserveCache(); + +var userCtrlStub = { + index: 'userCtrl.index', + destroy: 'userCtrl.destroy', + me: 'userCtrl.me', + changePassword: 'userCtrl.changePassword', + show: 'userCtrl.show', + create: 'userCtrl.create' +}; + +var authServiceStub = { + isAuthenticated() { + return 'authService.isAuthenticated'; + }, + hasRole(role) { + return `authService.hasRole.${role}`; + } +}; + +var routerStub = { + get: sinon.spy(), + put: sinon.spy(), + post: sinon.spy(), + delete: sinon.spy() +}; + +// require the index with our stubbed out modules +var userIndex = proxyquire('./index', { + express: { + Router() { + return routerStub; + } + }, + './user.controller': userCtrlStub, + '../../auth/auth.service': authServiceStub +}); + +describe('User API Router:', function() { + it('should return an express router instance', function() { + expect(userIndex).to.equal(routerStub); + }); + + describe('GET /api/users', function() { + it('should verify admin role and route to user.controller.index', function() { + expect(routerStub.get + .withArgs('/', 'authService.hasRole.admin', 'userCtrl.index') + ).to.have.been.calledOnce; + }); + }); + + describe('DELETE /api/users/:id', function() { + it('should verify admin role and route to user.controller.destroy', function() { + expect(routerStub.delete + .withArgs('/:id', 'authService.hasRole.admin', 'userCtrl.destroy') + ).to.have.been.calledOnce; + }); + }); + + describe('GET /api/users/me', function() { + it('should be authenticated and route to user.controller.me', function() { + expect(routerStub.get + .withArgs('/me', 'authService.isAuthenticated', 'userCtrl.me') + ).to.have.been.calledOnce; + }); + }); + + describe('PUT /api/users/:id/password', function() { + it('should be authenticated and route to user.controller.changePassword', function() { + expect(routerStub.put + .withArgs('/:id/password', 'authService.isAuthenticated', 'userCtrl.changePassword') + ).to.have.been.calledOnce; + }); + }); + + describe('GET /api/users/:id', function() { + it('should be authenticated and route to user.controller.show', function() { + expect(routerStub.get + .withArgs('/:id', 'authService.isAuthenticated', 'userCtrl.show') + ).to.have.been.calledOnce; + }); + }); + + describe('POST /api/users', function() { + it('should route to user.controller.create', function() { + expect(routerStub.post + .withArgs('/', 'userCtrl.create') + ).to.have.been.calledOnce; + }); + }); +}); diff --git a/server/api/user/user.controller.js b/server/api/user/user.controller.js new file mode 100644 index 0000000..c1ab134 --- /dev/null +++ b/server/api/user/user.controller.js @@ -0,0 +1,122 @@ +'use strict'; + +import User from './user.model'; +import config from '../../config/environment'; +import jwt from 'jsonwebtoken'; + +function validationError(res, statusCode) { + statusCode = statusCode || 422; + return function(err) { + return res.status(statusCode).json(err); + }; +} + +function handleError(res, statusCode) { + statusCode = statusCode || 500; + return function(err) { + return res.status(statusCode).send(err); + }; +} + +/** + * Get list of users + * restriction: 'admin' + */ +export function index(req, res) { + return User.find({}, '-salt -password').exec() + .then(users => { + res.status(200).json(users); + }) + .catch(handleError(res)); +} + +/** + * Creates a new user + */ +export function create(req, res) { + var newUser = new User(req.body); + newUser.provider = 'local'; + newUser.role = 'user'; + newUser.save() + .then(function(user) { + var token = jwt.sign({ _id: user._id }, config.secrets.session, { + expiresIn: 60 * 60 * 5 + }); + res.json({ token }); + }) + .catch(validationError(res)); +} + +/** + * Get a single user + */ +export function show(req, res, next) { + var userId = req.params.id; + + return User.findById(userId).exec() + .then(user => { + if(!user) { + return res.status(404).end(); + } + res.json(user.profile); + }) + .catch(err => next(err)); +} + +/** + * Deletes a user + * restriction: 'admin' + */ +export function destroy(req, res) { + return User.findByIdAndRemove(req.params.id).exec() + .then(function() { + res.status(204).end(); + }) + .catch(handleError(res)); +} + +/** + * Change a users password + */ +export function changePassword(req, res) { + var userId = req.user._id; + var oldPass = String(req.body.oldPassword); + var newPass = String(req.body.newPassword); + + return User.findById(userId).exec() + .then(user => { + if(user.authenticate(oldPass)) { + user.password = newPass; + return user.save() + .then(() => { + res.status(204).end(); + }) + .catch(validationError(res)); + } else { + return res.status(403).end(); + } + }); +} + +/** + * Get my info + */ +export function me(req, res, next) { + var userId = req.user._id; + + return User.findOne({ _id: userId }, '-salt -password').exec() + .then(user => { // don't ever give out the password or salt + if(!user) { + return res.status(401).end(); + } + res.json(user); + }) + .catch(err => next(err)); +} + +/** + * Authentication callback + */ +export function authCallback(req, res) { + res.redirect('/'); +} diff --git a/server/api/user/user.events.js b/server/api/user/user.events.js new file mode 100644 index 0000000..bf98a57 --- /dev/null +++ b/server/api/user/user.events.js @@ -0,0 +1,35 @@ +/** + * User model events + */ + +'use strict'; + +import {EventEmitter} from 'events'; +var UserEvents = new EventEmitter(); + +// Set max event listeners (0 == unlimited) +UserEvents.setMaxListeners(0); + +// Model events +var events = { + save: 'save', + remove: 'remove' +}; + +// Register the event emitter to the model events +function registerEvents(User) { + for(var e in events) { + let event = events[e]; + User.post(e, emitEvent(event)); + } +} + +function emitEvent(event) { + return function(doc) { + UserEvents.emit(`${event}:${doc._id}`, doc); + UserEvents.emit(event, doc); + }; +} + +export {registerEvents}; +export default UserEvents; diff --git a/server/api/user/user.integration.js b/server/api/user/user.integration.js new file mode 100644 index 0000000..9ef0176 --- /dev/null +++ b/server/api/user/user.integration.js @@ -0,0 +1,67 @@ +'use strict'; + +/* globals describe, expect, it, before, after, beforeEach, afterEach */ + +import app from '../..'; +import User from './user.model'; +import request from 'supertest'; + +describe('User API:', function() { + var user; + + // Clear users before testing + before(function() { + return User.remove().then(function() { + user = new User({ + name: 'Fake User', + email: 'test@example.com', + password: 'password' + }); + + return user.save(); + }); + }); + + // Clear users after testing + after(function() { + return User.remove(); + }); + + describe('GET /api/users/me', function() { + var token; + + before(function(done) { + request(app) + .post('/auth/local') + .send({ + email: 'test@example.com', + password: 'password' + }) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + token = res.body.token; + done(); + }); + }); + + it('should respond with a user profile when authenticated', function(done) { + request(app) + .get('/api/users/me') + .set('authorization', `Bearer ${token}`) + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + expect(res.body._id.toString()).to.equal(user._id.toString()); + done(); + }); + }); + + it('should respond with a 401 when not authenticated', function(done) { + request(app) + .get('/api/users/me') + .expect(401) + .end(done); + }); + }); +}); diff --git a/server/api/user/user.model.js b/server/api/user/user.model.js new file mode 100644 index 0000000..6ac0415 --- /dev/null +++ b/server/api/user/user.model.js @@ -0,0 +1,258 @@ +'use strict'; +/*eslint no-invalid-this:0*/ +import crypto from 'crypto'; +mongoose.Promise = require('bluebird'); +import mongoose, {Schema} from 'mongoose'; +import {registerEvents} from './user.events'; + +const authTypes = ['github', 'twitter', 'facebook', 'google']; + +var UserSchema = new Schema({ + name: String, + email: { + type: String, + lowercase: true, + required() { + if(authTypes.indexOf(this.provider) === -1) { + return true; + } else { + return false; + } + } + }, + role: { + type: String, + default: 'user' + }, + password: { + type: String, + required() { + if(authTypes.indexOf(this.provider) === -1) { + return true; + } else { + return false; + } + } + }, + provider: String, + salt: String, + facebook: {}, + google: {}, + github: {} +}); + +/** + * Virtuals + */ + +// Public profile information +UserSchema + .virtual('profile') + .get(function() { + return { + name: this.name, + role: this.role + }; + }); + +// Non-sensitive info we'll be putting in the token +UserSchema + .virtual('token') + .get(function() { + return { + _id: this._id, + role: this.role + }; + }); + +/** + * Validations + */ + +// Validate empty email +UserSchema + .path('email') + .validate(function(email) { + if(authTypes.indexOf(this.provider) !== -1) { + return true; + } + return email.length; + }, 'Email cannot be blank'); + +// Validate empty password +UserSchema + .path('password') + .validate(function(password) { + if(authTypes.indexOf(this.provider) !== -1) { + return true; + } + return password.length; + }, 'Password cannot be blank'); + +// Validate email is not taken +UserSchema + .path('email') + .validate(function(value) { + if(authTypes.indexOf(this.provider) !== -1) { + return true; + } + + return this.constructor.findOne({ email: value }).exec() + .then(user => { + if(user) { + if(this.id === user.id) { + return true; + } + return false; + } + return true; + }) + .catch(function(err) { + throw err; + }); + }, 'The specified email address is already in use.'); + +var validatePresenceOf = function(value) { + return value && value.length; +}; + +/** + * Pre-save hook + */ +UserSchema + .pre('save', function(next) { + // Handle new/update passwords + if(!this.isModified('password')) { + return next(); + } + + if(!validatePresenceOf(this.password)) { + if(authTypes.indexOf(this.provider) === -1) { + return next(new Error('Invalid password')); + } else { + return next(); + } + } + + // Make salt with a callback + this.makeSalt((saltErr, salt) => { + if(saltErr) { + return next(saltErr); + } + this.salt = salt; + this.encryptPassword(this.password, (encryptErr, hashedPassword) => { + if(encryptErr) { + return next(encryptErr); + } + this.password = hashedPassword; + return next(); + }); + }); + }); + +/** + * Methods + */ +UserSchema.methods = { + /** + * Authenticate - check if the passwords are the same + * + * @param {String} password + * @param {Function} callback + * @return {Boolean} + * @api public + */ + authenticate(password, callback) { + if(!callback) { + return this.password === this.encryptPassword(password); + } + + this.encryptPassword(password, (err, pwdGen) => { + if(err) { + return callback(err); + } + + if(this.password === pwdGen) { + return callback(null, true); + } else { + return callback(null, false); + } + }); + }, + + /** + * Make salt + * + * @param {Number} [byteSize] - Optional salt byte size, default to 16 + * @param {Function} callback + * @return {String} + * @api public + */ + makeSalt(...args) { + let byteSize; + let callback; + let defaultByteSize = 16; + + if(typeof args[0] === 'function') { + callback = args[0]; + byteSize = defaultByteSize; + } else if(typeof args[1] === 'function') { + callback = args[1]; + } else { + throw new Error('Missing Callback'); + } + + if(!byteSize) { + byteSize = defaultByteSize; + } + + return crypto.randomBytes(byteSize, (err, salt) => { + if(err) { + return callback(err); + } else { + return callback(null, salt.toString('base64')); + } + }); + }, + + /** + * Encrypt password + * + * @param {String} password + * @param {Function} callback + * @return {String} + * @api public + */ + encryptPassword(password, callback) { + if(!password || !this.salt) { + if(!callback) { + return null; + } else { + return callback('Missing password or salt'); + } + } + + var defaultIterations = 10000; + var defaultKeyLength = 64; + var salt = new Buffer(this.salt, 'base64'); + + if(!callback) { + // eslint-disable-next-line no-sync + return crypto.pbkdf2Sync(password, salt, defaultIterations, + defaultKeyLength, 'sha1') + .toString('base64'); + } + + return crypto.pbkdf2(password, salt, defaultIterations, defaultKeyLength, + 'sha1', (err, key) => { + if(err) { + return callback(err); + } else { + return callback(null, key.toString('base64')); + } + }); + } +}; + +registerEvents(UserSchema); +export default mongoose.model('User', UserSchema); diff --git a/server/api/user/user.model.spec.js b/server/api/user/user.model.spec.js new file mode 100644 index 0000000..e4d0d8b --- /dev/null +++ b/server/api/user/user.model.spec.js @@ -0,0 +1,164 @@ +'use strict'; + +import app from '../..'; +import User from './user.model'; +var user; +var genUser = function() { + user = new User({ + provider: 'local', + name: 'Fake User', + email: 'test@example.com', + password: 'password' + }); + return user; +}; + +describe('User Model', function() { + before(function() { + // Clear users before testing + return User.remove(); + }); + + beforeEach(function() { + genUser(); + }); + + afterEach(function() { + return User.remove(); + }); + + it('should begin with no users', function() { + return expect(User.find({}).exec()).to + .eventually.have.length(0); + }); + + it('should fail when saving a duplicate user', function() { + return expect(user.save() + .then(function() { + var userDup = genUser(); + return userDup.save(); + })).to.be.rejected; + }); + + describe('#email', function() { + it('should fail when saving with a blank email', function() { + user.email = ''; + return expect(user.save()).to.be.rejected; + }); + + it('should fail when saving with a null email', function() { + user.email = null; + return expect(user.save()).to.be.rejected; + }); + + it('should fail when saving without an email', function() { + user.email = undefined; + return expect(user.save()).to.be.rejected; + }); + + describe('given user provider is google', function() { + beforeEach(function() { + user.provider = 'google'; + }); + + it('should succeed when saving without an email', function() { + user.email = null; + return expect(user.save()).to.be.fulfilled; + }); + }); + + describe('given user provider is facebook', function() { + beforeEach(function() { + user.provider = 'facebook'; + }); + + it('should succeed when saving without an email', function() { + user.email = null; + return expect(user.save()).to.be.fulfilled; + }); + }); + + describe('given user provider is github', function() { + beforeEach(function() { + user.provider = 'github'; + }); + + it('should succeed when saving without an email', function() { + user.email = null; + return expect(user.save()).to.be.fulfilled; + }); + }); + }); + + describe('#password', function() { + it('should fail when saving with a blank password', function() { + user.password = ''; + return expect(user.save()).to.be.rejected; + }); + + it('should fail when saving with a null password', function() { + user.password = null; + return expect(user.save()).to.be.rejected; + }); + + it('should fail when saving without a password', function() { + user.password = undefined; + return expect(user.save()).to.be.rejected; + }); + + describe('given the user has been previously saved', function() { + beforeEach(function() { + return user.save(); + }); + + it('should authenticate user if valid', function() { + expect(user.authenticate('password')).to.be.true; + }); + + it('should not authenticate user if invalid', function() { + expect(user.authenticate('blah')).to.not.be.true; + }); + + it('should remain the same hash unless the password is updated', function() { + user.name = 'Test User'; + return expect(user.save() + .then(function(u) { + return u.authenticate('password'); + })).to.eventually.be.true; + }); + }); + + describe('given user provider is google', function() { + beforeEach(function() { + user.provider = 'google'; + }); + + it('should succeed when saving without a password', function() { + user.password = null; + return expect(user.save()).to.be.fulfilled; + }); + }); + + describe('given user provider is facebook', function() { + beforeEach(function() { + user.provider = 'facebook'; + }); + + it('should succeed when saving without a password', function() { + user.password = null; + return expect(user.save()).to.be.fulfilled; + }); + }); + + describe('given user provider is github', function() { + beforeEach(function() { + user.provider = 'github'; + }); + + it('should succeed when saving without a password', function() { + user.password = null; + return expect(user.save()).to.be.fulfilled; + }); + }); + }); +}); diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..d743048 --- /dev/null +++ b/server/app.js @@ -0,0 +1,38 @@ +/** + * Main application file + */ + +'use strict'; + +import express from 'express'; +import mongoose from 'mongoose'; +mongoose.Promise = require('bluebird'); +import config from './config/environment'; +import http from 'http'; +import seedDatabaseIfNeeded from './config/seed'; + +// Connect to MongoDB +mongoose.connect(config.mongo.uri, config.mongo.options); +mongoose.connection.on('error', function(err) { + console.error(`MongoDB connection error: ${err}`); + process.exit(-1); // eslint-disable-line no-process-exit +}); + +// Setup server +var app = express(); +var server = http.createServer(app); +require('./config/express').default(app); +require('./routes').default(app); + +// Start server +function startServer() { + app.angularFullstack = server.listen(config.port, config.ip, function() { + console.log('Express server listening on %d, in %s mode', config.port, app.get('env')); + }); +} + +seedDatabaseIfNeeded(); +setImmediate(startServer); + +// Expose app +exports = module.exports = app; diff --git a/server/auth/auth.service.js b/server/auth/auth.service.js new file mode 100644 index 0000000..4a42130 --- /dev/null +++ b/server/auth/auth.service.js @@ -0,0 +1,82 @@ +'use strict'; +import config from '../config/environment'; +import jwt from 'jsonwebtoken'; +import expressJwt from 'express-jwt'; +import compose from 'composable-middleware'; +import User from '../api/user/user.model'; + +var validateJwt = expressJwt({ + secret: config.secrets.session +}); + +/** + * Attaches the user object to the request if authenticated + * Otherwise returns 403 + */ +export function isAuthenticated() { + return compose() + // Validate jwt + .use(function(req, res, next) { + // allow access_token to be passed through query parameter as well + if(req.query && req.query.hasOwnProperty('access_token')) { + req.headers.authorization = `Bearer ${req.query.access_token}`; + } + // IE11 forgets to set Authorization header sometimes. Pull from cookie instead. + if(req.query && typeof req.headers.authorization === 'undefined') { + req.headers.authorization = `Bearer ${req.cookies.token}`; + } + validateJwt(req, res, next); + }) + // Attach user to request + .use(function(req, res, next) { + User.findById(req.user._id).exec() + .then(user => { + if(!user) { + return res.status(401).end(); + } + req.user = user; + next(); + }) + .catch(err => next(err)); + }); +} + +/** + * Checks if the user role meets the minimum requirements of the route + */ +export function hasRole(roleRequired) { + if(!roleRequired) { + throw new Error('Required role needs to be set'); + } + + return compose() + .use(isAuthenticated()) + .use(function meetsRequirements(req, res, next) { + if(config.userRoles.indexOf(req.user.role) >= config.userRoles.indexOf(roleRequired)) { + return next(); + } else { + return res.status(403).send('Forbidden'); + } + }); +} + +/** + * Returns a jwt token signed by the app secret + */ +export function signToken(id, role) { + return jwt.sign({ _id: id, role }, config.secrets.session, { + expiresIn: 60 * 60 * 5 + }); +} + +/** + * Set token cookie directly for oAuth strategies + */ +export function setTokenCookie(req, res) { + if(!req.user) { + return res.status(404).send('It looks like you aren\'t logged in, please try again.'); + } + var token = signToken(req.user._id, req.user.role); + res.cookie('token', token); + res.redirect('/'); +} diff --git a/server/auth/facebook/index.js b/server/auth/facebook/index.js new file mode 100644 index 0000000..f0c98ef --- /dev/null +++ b/server/auth/facebook/index.js @@ -0,0 +1,20 @@ +'use strict'; + +import express from 'express'; +import passport from 'passport'; +import {setTokenCookie} from '../auth.service'; + +var router = express.Router(); + +router + .get('/', passport.authenticate('facebook', { + scope: ['email', 'user_about_me'], + failureRedirect: '/signup', + session: false + })) + .get('/callback', passport.authenticate('facebook', { + failureRedirect: '/signup', + session: false + }), setTokenCookie); + +export default router; diff --git a/server/auth/facebook/passport.js b/server/auth/facebook/passport.js new file mode 100644 index 0000000..7046e0a --- /dev/null +++ b/server/auth/facebook/passport.js @@ -0,0 +1,34 @@ +import passport from 'passport'; +import {Strategy as FacebookStrategy} from 'passport-facebook'; + +export function setup(User, config) { + passport.use(new FacebookStrategy({ + clientID: config.facebook.clientID, + clientSecret: config.facebook.clientSecret, + callbackURL: config.facebook.callbackURL, + profileFields: [ + 'displayName', + 'emails' + ] + }, + function(accessToken, refreshToken, profile, done) { + User.findOne({'facebook.id': profile.id}).exec() + .then(user => { + if(user) { + return done(null, user); + } + + user = new User({ + name: profile.displayName, + email: profile.emails[0].value, + role: 'user', + provider: 'facebook', + facebook: profile._json + }); + user.save() + .then(savedUser => done(null, savedUser)) + .catch(err => done(err)); + }) + .catch(err => done(err)); + })); +} diff --git a/server/auth/google/index.js b/server/auth/google/index.js new file mode 100644 index 0000000..25753de --- /dev/null +++ b/server/auth/google/index.js @@ -0,0 +1,23 @@ +'use strict'; + +import express from 'express'; +import passport from 'passport'; +import {setTokenCookie} from '../auth.service'; + +var router = express.Router(); + +router + .get('/', passport.authenticate('google', { + failureRedirect: '/signup', + scope: [ + 'profile', + 'email' + ], + session: false + })) + .get('/callback', passport.authenticate('google', { + failureRedirect: '/signup', + session: false + }), setTokenCookie); + +export default router; diff --git a/server/auth/google/passport.js b/server/auth/google/passport.js new file mode 100644 index 0000000..a0f8347 --- /dev/null +++ b/server/auth/google/passport.js @@ -0,0 +1,31 @@ +import passport from 'passport'; +import {Strategy as GoogleStrategy} from 'passport-google-oauth20'; + +export function setup(User, config) { + passport.use(new GoogleStrategy({ + clientID: config.google.clientID, + clientSecret: config.google.clientSecret, + callbackURL: config.google.callbackURL + }, + function(accessToken, refreshToken, profile, done) { + User.findOne({'google.id': profile.id}).exec() + .then(user => { + if(user) { + return done(null, user); + } + + user = new User({ + name: profile.displayName, + email: profile.emails[0].value, + role: 'user', + username: profile.emails[0].value.split('@')[0], + provider: 'google', + google: profile._json + }); + user.save() + .then(savedUser => done(null, savedUser)) + .catch(err => done(err)); + }) + .catch(err => done(err)); + })); +} diff --git a/server/auth/index.js b/server/auth/index.js new file mode 100644 index 0000000..9b04d8b --- /dev/null +++ b/server/auth/index.js @@ -0,0 +1,17 @@ +'use strict'; +import express from 'express'; +import config from '../config/environment'; +import User from '../api/user/user.model'; + +// Passport Configuration +require('./local/passport').setup(User, config); +require('./facebook/passport').setup(User, config); +require('./google/passport').setup(User, config); + +var router = express.Router(); + +router.use('/local', require('./local').default); +router.use('/facebook', require('./facebook').default); +router.use('/google', require('./google').default); + +export default router; diff --git a/server/auth/local/index.js b/server/auth/local/index.js new file mode 100644 index 0000000..08ebf69 --- /dev/null +++ b/server/auth/local/index.js @@ -0,0 +1,24 @@ +'use strict'; + +import express from 'express'; +import passport from 'passport'; +import {signToken} from '../auth.service'; + +var router = express.Router(); + +router.post('/', function(req, res, next) { + passport.authenticate('local', function(err, user, info) { + var error = err || info; + if(error) { + return res.status(401).json(error); + } + if(!user) { + return res.status(404).json({message: 'Something went wrong, please try again.'}); + } + + var token = signToken(user._id, user.role); + res.json({ token }); + })(req, res, next); +}); + +export default router; diff --git a/server/auth/local/passport.js b/server/auth/local/passport.js new file mode 100644 index 0000000..dc52228 --- /dev/null +++ b/server/auth/local/passport.js @@ -0,0 +1,35 @@ +import passport from 'passport'; +import {Strategy as LocalStrategy} from 'passport-local'; + +function localAuthenticate(User, email, password, done) { + User.findOne({ + email: email.toLowerCase() + }).exec() + .then(user => { + if(!user) { + return done(null, false, { + message: 'This email is not registered.' + }); + } + user.authenticate(password, function(authError, authenticated) { + if(authError) { + return done(authError); + } + if(!authenticated) { + return done(null, false, { message: 'This password is not correct.' }); + } else { + return done(null, user); + } + }); + }) + .catch(err => done(err)); +} + +export function setup(User/*, config*/) { + passport.use(new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' // this is the virtual field on the model + }, function(email, password, done) { + return localAuthenticate(User, email, password, done); + })); +} diff --git a/server/components/ansible/ansible_tool.js b/server/components/ansible/ansible_tool.js new file mode 100644 index 0000000..5e866c5 --- /dev/null +++ b/server/components/ansible/ansible_tool.js @@ -0,0 +1,658 @@ +var ssh2_exec = require('../ssh/ssh2_exec'); +var scp2_exec = require('../scp/scp_exec'); +var config = require('../../config/environment'); + +var local_logPath = 'logs/ansible/execute/' + +exports.getLogs = function(logfilename,successCallback,errorCallback){ + var logFile = local_logPath + logfilename; + var fs = require('fs'); + fs.readFile(logFile, function(err, data){ + if(err){ + errorCallback(err); + }else{ + successCallback(data); + } + + }); +}; + + +exports.getModules = function(dataCallback, successCallback,errorCallback, ansibleEngine){ + + var command = 'ansible-doc -l'; + + if(ansibleEngine.customModules){ + command = 'export ANSIBLE_LIBRARY="' + ansibleEngine.customModules + '"; ' + command; + } + + ssh2_exec.executeCommand(command, + null, + function(data){ + successCallback(data); + }, + function(data){ + errorCallback(data) + }, + ansibleEngine + ) +}; + +exports.getAnsibleVersion = function(successCallback,errorCallback, ansibleEngine){ + var command = 'ansible --version'; + var ansibleVersionResult = ""; + + ssh2_exec.executeCommand(command, + null, + function(data){ + ansibleVersionResult=data; + console.log("Ansible Verison =" + ansibleVersionResult); + ansibleVersionResult = "" + ansibleVersionResult; + var version = ansibleVersionResult.replace(/ansible (.*)[^]+/,"$1"); + console.log("Version=" + version); + successCallback(version || ansibleVersionResult); + }, + function(data){ + errorCallback(data) + }, + ansibleEngine + ) +}; + + +exports.executeAnsible = function(logfilename,project_folder, playbook_name, inventory_file_name, tags_joined, limit_to_hosts_joined, verbose,check_mode,dataCallback, successCallback,errorCallback,ansibleEngine){ + + var fs = require('filendir'); + var time = new Date().getTime(); + var logFile = local_logPath + logfilename; + + fs.writeFileSync(logFile,"Executing Ansible Playbook \n\n",{'flag':'a'}); + fs.writeFileSync(logFile," Completed \n",{'flag':'a'}); + + // export ANSIBLE_GATHERING=FALSE; + var command= 'export ANSIBLE_FORCE_COLOR=true; export ANSIBLE_HOST_KEY_CHECKING=False; cd "' + project_folder + '"; ansible-playbook --vault-password-file ~/.vault_pass.txt "' + playbook_name + '" -i "' + inventory_file_name + '"'; + + if(ansibleEngine.customModules){ + command = 'export ANSIBLE_LIBRARY="' + ansibleEngine.customModules + '"; ' + command; + } + + if(tags_joined) + command += ' --tags "' + tags_joined + '"'; + //command += ' --tags "' + tags.join(",") + '"'; + + if(limit_to_hosts_joined) + command += ' --limit "' + limit_to_hosts_joined + '"'; + + if(verbose === 'verbose_detail'){ + command += ' -vvv '; + } + else if(verbose === 'verbose'){ + command += ' -v '; + } + + if(check_mode !== 'No_Check'){ + command += ' --check '; + } + + console.log("Command= " + command); + + //fs.writeFileSync(logFile,"\n Executing Command =" + command + "\n",{'flag':'a'}); + fs.writeFile(logFile,"\n Executing Command =" + command + "\n"); + + ssh2_exec.executeCommand(command,function(response){ + //Calling datacallbcak back as the call is Asynchronous + //Logs are queried to check status + dataCallback(response); + console.log(response); + //fs.writeFile(logFile,response,{'flag':'a'}); + fs.writeFile(logFile,"\n Executing Command =" + command + "\n" + response); + },function(response){ + successCallback(response); + console.log(response); + //fs.writeFile(logFile,response,{'flag':'a'}); + },function(response){ + errorCallback(response); + console.log(response); + fs.writeFile(logFile,response,{'flag':'a'}); + },ansibleEngine, true //addScriptEndString + ) + +}; + + + +exports.getVars = function(project_folder, inventory_file_name, host_name, dataCallback, successCallback,errorCallback,ansibleEngine){ + + var fs = require('filendir'); + + var AnsibleAPILocation = '/opt/ehc-builder-scripts/ansible_modules/AnsibleAPI.py'; + + var command= 'cd "' + project_folder + '"; python "' + AnsibleAPILocation + '" host_vars --inventory_file="' + inventory_file_name + '"'; + + if(host_name){ + command += ' --host_name ' + host_name; + } + + if(ansibleEngine.customModules){ + command = 'export ANSIBLE_LIBRARY="' + ansibleEngine.customModules + '"; ' + command; + } + + console.log("Command= " + command); + + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine); + +}; + + +exports.getRolesVars = function(project_folder, role_name, dataCallback, successCallback,errorCallback,ansibleEngine){ + + var AnsibleAPILocation = '/opt/ehc-builder-scripts/ansible_modules/AnsibleAPI.py'; + + var project_roles_folder = project_folder + '/roles'; + var playbook_path = role_name + '/tests/test.yml'; + var command= 'cd "' + project_roles_folder + '"; python "' + AnsibleAPILocation + '" role_vars --playbook_path="' + playbook_path + '" --vault_password_file ~/.vault_pass.txt'; + + if(ansibleEngine.customModules){ + command = 'export ANSIBLE_LIBRARY="' + ansibleEngine.customModules + '"; ' + command; + } + + console.log("Command= " + command); + + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine); + +}; + +/* +exports.executeAnsible = function(logfilename,inventory_file_contents,playbook_file_contents,tags,verbose,check_mode,dataCallback, successCallback,errorCallback,ansibleEngine){ + + var fs = require('filendir'); + var time = new Date().getTime(); + var logFile = local_logPath + logfilename; + + fs.writeFileSync(logFile,"Executing Ansible Playbook \n\n",{'flag':'a'}); + + var inventory_file_name = 'inventory_file_' + time + '.ini'; + var playbook_file_name = 'playbook_file_' + time + '.yml'; + + fs.writeFileSync(logFile,"inventory_file_location - " + inventory_file_name +" \n",{'flag':'a'}); + fs.writeFileSync(logFile,"playbook_file_location - " + playbook_file_name +" \n\n",{'flag':'a'}); + + console.log('inventory_file_name=' + inventory_file_name); + + var inputFilePathOnScriptEngine = config.scriptEngine.inputDirectory + '/inventory/' + inventory_file_name; + var playbookFilePathOnScriptEngine = config.scriptEngine.inputDirectory + '/playbooks/' + playbook_file_name; + + fs.writeFileSync(logFile,"Writing inventory file to Script Engine - " + inputFilePathOnScriptEngine +" - ",{'flag':'a'}); + + scp2_exec.createFileOnScriptEngine(inventory_file_contents,inputFilePathOnScriptEngine, + function(){ + console.log("Inventory file written"); + fs.writeFileSync(logFile," Completed \n",{'flag':'a'}); + fs.writeFileSync(logFile,"Writing playbook file to Script Engine - " + playbookFilePathOnScriptEngine +" - ",{'flag':'a'}); + scp2_exec.createFileOnScriptEngine(playbook_file_contents,playbookFilePathOnScriptEngine, + function(){ + console.log("Playbook file written"); + fs.writeFileSync(logFile," Completed \n",{'flag':'a'}); + + var command= "export ANSIBLE_HOST_KEY_CHECKING=False; ansible-playbook --vault-password-file ~/.vault_pass.txt " + playbookFilePathOnScriptEngine + " -i " + inputFilePathOnScriptEngine; + + if(ansibleEngine.customModules){ + command = 'export ANSIBLE_LIBRARY=' + ansibleEngine.customModules + '; ' + command; + } + + if(tags) + command += ' --tags "' + tags.join(",") + '"'; + + if(verbose === 'verbose_detail'){ + command += ' -vvv '; + } + else if(verbose === 'verbose'){ + command += ' -v '; + } + + if(check_mode !== 'No_Check'){ + command += ' --check '; + } + + console.log("Command= " + command); + + fs.writeFileSync(logFile,"\n Executing Command =" + command + "\n",{'flag':'a'}); + + ssh2_exec.executeCommand(command,function(response){ + dataCallback(response); + console.log(response); + fs.writeFile(logFile,response,{'flag':'a'}); + },function(response){ + successCallback(response); + console.log(response); + fs.writeFile(logFile,response,{'flag':'a'}); + },function(response){ + errorCallback(response); + console.log(response); + fs.writeFile(logFile,response,{'flag':'a'}); + },ansibleEngine) + + },function(err){ + errorCallback(err); + fs.writeFile(logFile," Failed \n",{'flag':'a'}); + },ansibleEngine); + + },function(err){ + errorCallback(err); + fs.writeFile(logFile," Failed \n",{'flag':'a'}); + },ansibleEngine); + +}; +*/ + + + +exports.writeFile = function(file_path,file_contents, successCallback,errorCallback,ansibleEngine){ + + scp2_exec.createFileOnScriptEngine(file_contents, file_path, + function(){ + successCallback('file written'); + },function(err){ + errorCallback(err); + },ansibleEngine); + +}; + +exports.deleteFile = function(file_path,successCallback,errorCallback,ansibleEngine){ + + var command = 'rm -rf "' + file_path + '"'; + + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine) + +}; + +exports.readFile = function(file_path, dataCallback, successCallback,errorCallback,ansibleEngine){ + + var command = 'cat "' + file_path + '"'; + + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine) + +}; + +exports.writePlaybook = function(project_folder,playbook_file_name,playbook_file_contents, successCallback,errorCallback,ansibleEngine){ + + var playbook_file_path = '' + project_folder + '/' + playbook_file_name + ''; + + console.log('playbook_file_path=' + playbook_file_path); + + scp2_exec.createFileOnScriptEngine(playbook_file_contents, playbook_file_path, + function(){ + console.log('playbook_file written'); + successCallback('playbook_file written'); + },function(err){ + errorCallback(err); + },ansibleEngine); + +}; + + +exports.readPlaybook = function(project_folder,playbook_file_name, dataCallback, successCallback,errorCallback,ansibleEngine){ + + var playbook_file_path = project_folder + '/' + playbook_file_name; + var command = 'cat "' + playbook_file_path + '"'; + + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine) + +}; + + +exports.deletePlaybook = function(project_folder,playbook_file_name, dataCallback, successCallback,errorCallback,ansibleEngine){ + + var playbook_file_path = project_folder + '/' + playbook_file_name; + var command = 'rm -f "' + playbook_file_path + '"'; + + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine) + +}; + +exports.getPlaybookList = function(project_folder, successCallback, errorCallback, ansibleEngine){ + + var playbook_file_path = project_folder + '/'; + var command = 'ls "' + playbook_file_path + '" | grep .yml'; + var ansiblePlaybookListResults = ""; + + ssh2_exec.executeCommand(command, + null, + function(data){ + ansiblePlaybookListResults=data; + var files = []; + if(ansiblePlaybookListResults) + files = ansiblePlaybookListResults.trim().split('\n'); + successCallback(files); + }, + function(data){ + errorCallback(data) + }, + ansibleEngine + ) + +}; + +/** + * Get list of roles from project + * @param project_folder - Project root folder + * @param successCallback - Success Callback method + * @param errorCallback - Error Callback method + * @param ansibleEngine - Remote Ansible Engine details + */ +exports.getRolesList = function(project_folder, successCallback, errorCallback, ansibleEngine){ + + var playbook_file_path = project_folder + '/roles'; + var command = 'ls "' + playbook_file_path + '"'; + var ansiblePlaybookListResults = ""; + + ssh2_exec.executeCommand(command, + null, + function(data){ + ansiblePlaybookListResults=data; + var roles = []; + if(ansiblePlaybookListResults) + roles = ansiblePlaybookListResults.trim().split('\n'); + successCallback(roles); + }, + function(data){ + errorCallback(data) + }, + ansibleEngine + ) + +}; + + +/** + * Create Project Folder + * @param project_folder - Project folder to create + * @param successCallback + * @param errorCallback + * @param ansibleEngine - Remote Ansible Engine details + */ +exports.createProjectFolder = function(project_folder, successCallback, errorCallback, ansibleEngine){ + + var librarypath = project_folder + '/library'; + var rolespath = project_folder + '/roles'; + var command = 'mkdir -p "' + librarypath + '"; mkdir -p "' + rolespath + '"'; + + var check_dir_command = '[ ! -d ' + project_folder + ' ]'; + + ssh2_exec.executeCommand(check_dir_command,null,function(data){ + ssh2_exec.executeCommand(command,null,successCallback,errorCallback,ansibleEngine); + },function(){ + errorCallback("Directory - " + project_folder +" already exists. Try a different Project Folder path.") + },ansibleEngine); + +}; + +/** + * Search roles in ansible-galaxy + * Use ansible-galaxy search command + * @param searchText - Text to search + * @param successCallback + * @param errorCallback + * @param ansibleEngine - Remote Ansible Engine details + */ +exports.searchRolesGalaxy = function(searchText, successCallback, errorCallback, ansibleEngine){ + + var command = 'ansible-galaxy search ' + searchText; + console.log('Command = ' + command); + ssh2_exec.executeCommand(command,null,function(response){ + + console.log("Galaxy Response =" + response); + + if(response.indexOf('No roles match your search.') > -1){ + return errorCallback('No roles match your search.') + }else{ + var str = response.replace(/[^]+--\n([^]+)/g,'$1'); + + var re = /\s+(.*?)\s+(.*)/gm; + var m; + var results = []; + while ((m = re.exec(str)) !== null) { + if (m.index === re.lastIndex) { + re.lastIndex++; + } + // View your result using the m-variable. + // eg m[0] etc. + + results.push({'name':m[1],'description':m[2],'type':'galaxy'}) + + } + + successCallback(results); + + } + + }, errorCallback,ansibleEngine); + +}; + +/** + * Search Roles in GitHub + * Uses uri https://api.github.com/search/repositories?q=ansible-role- + * @param searchText + * @param successCallback + * @param errorCallback + * @param ansibleEngine + */ +exports.searchRolesGithub = function(searchText, successCallback, errorCallback, ansibleEngine){ + + var https = require('https'); + var options = { + host: 'api.github.com', + path: '/search/repositories?q=ansible-role-' + searchText, + headers: {'user-agent': 'node.js'} + }; + + console.log("path " + '/search/repositories?q=ansible-role' + searchText) + + var req = https.get(options, function(res) { + console.log('STATUS: ' + res.statusCode); + console.log('HEADERS: ' + JSON.stringify(res.headers)); + console.log('DATA: ' + JSON.stringify(res.data)); + + // Buffer the body entirely for processing as a whole. + var bodyChunks = []; + res.on('data', function(chunk) { + // You can process streamed parts here... + bodyChunks.push(chunk); + }).on('end', function() { + var body = Buffer.concat(bodyChunks); + console.log('BODY: ' + body); + + var json_results = JSON.parse(body); + var results = []; + console.log("Search Results = " + json_results.total_count); + for(var i=0;i { + // console.log(`Found match, group ${groupIndex}: ${match}`); + // + // }); + + var methodName = m[1]; + var docStringComments = '"""' + m[2] + '"""'; + + var regex2 = /"""[^]+(Description[^]+?)(Parameters[^]+?)?(Return[^]+?)?(Raise[^]+?)?"""/gm; + var description = docStringComments.replace(regex2, '$1'); + var Parameters = docStringComments.replace(regex2, '$2'); + var Return = docStringComments.replace(regex2, '$3'); + var Raise = docStringComments.replace(regex2, '$4'); + + var moduleName = path.parse(filename).name + var packageName = path.parse(path.parse(filename).dir).name + + // console.log('Package: %s', packageName); + // console.log('Module: %s', moduleName); + // console.log('Method: %s', methodName); + // console.log('Description: %s', description); + + var method = { + moduleName: moduleName, + methodName: methodName, + packageName: packageName, + description: description + } + + results.push(method) + // console.log('Parameters: %s', Parameters); + // console.log('Return: %s', Return); + // console.log('Raise: %s', Raise); + + } + + next(); + }, function(){ + + console.log(results); + +}); + + diff --git a/server/components/scp/scp_exec.js b/server/components/scp/scp_exec.js new file mode 100644 index 0000000..18eac86 --- /dev/null +++ b/server/components/scp/scp_exec.js @@ -0,0 +1,123 @@ +import config from '../../config/environment'; +//var config = require('../../config/environment/development.js'); +var client = require('scp2'); + + +exports.copyFileToScriptEngine = function(sourcePath,destinationPath,ansibleEngine){ + + var connHost = ansibleEngine.ansibleHost || config.scriptEngine.host; + var connUser = ansibleEngine.ansibleHostUser || config.scriptEngine.user; + var connHostPassword = ansibleEngine.ansibleHostPassword || config.scriptEngine.password; + + var scriptEngineConfig = { + host: connHost, + port: 22, + username: connUser, + tryKeyboard: true + }; + + if(connHostPassword){ + scriptEngineConfig.password = connHostPassword; + }else{ + scriptEngineConfig.privateKey = require('fs').readFileSync(config.scriptEngine.privateKey); + } + + scriptEngineConfig.destinationPath = destinationPath; + var Client = require('scp2').Client; + var cl = new Client(scriptEngineConfig); + + cl.on('keyboard-interactive', function(name, instr, lang, prompts, cb) { + cb([connHostPassword]); + }); + + cl.on('error', function(error) { + console.log("SCP Connect Error" + error); + return error + }); + + cl.upload(sourcePath,destinationPath,function(err) { + if(err){ + console.error(err) + }else{ + console.log("Successfully uploaded file") + cl.close() + } + }) +}; + +exports.createFileOnScriptEngine = function(contents,destinationPath,successCallback,errorCallback,ansibleEngine){ + var Client = require('scp2').Client; + var buffer = new Buffer(contents, "utf-8"); + + if(!ansibleEngine) ansibleEngine = {}; + + var connHost = ansibleEngine.ansibleHost || config.scriptEngine.host; + var connUser = ansibleEngine.ansibleHostUser || config.scriptEngine.user; + var connHostPassword = ansibleEngine.ansibleHostPassword || config.scriptEngine.password; + + var scriptEngineConfig = { + host: connHost, + port: 22, + username: connUser, + tryKeyboard: true + }; + + if(connHostPassword){ + scriptEngineConfig.password = connHostPassword; + }else{ + scriptEngineConfig.privateKey = require('fs').readFileSync(config.scriptEngine.privateKey); + } + + + var cl = new Client(scriptEngineConfig); + + cl.on('keyboard-interactive', function(name, instr, lang, prompts, cb) { + cb([connHostPassword]); + }); + + cl.on('error', function(error) { + console.log("SCP Connect Error" + error); + errorCallback(error); + }); + + //cl.connect(scriptEngineConfig); + + var dirname = destinationPath.match(/(.*)[\/\\]/)[1]||''; + + console.log("direcname = " + dirname); + + cl.mkdir(dirname,function(err){ + if(err){ + errorCallback('Failed to create directory - ' + dirname + ' -' + err) + return cl.close() + } + + cl.write({ + destination: destinationPath, + content: buffer + }, function(err){ + if(err){ + console.error(err); + errorCallback(err); + + }else{ + console.log("Success "); + successCallback() + } + cl.close() + }); + + }); + + + +}; + +//exports.copyFileToScriptEngine('scp_exec.js','/tmp/ssh_tezt.js'); +/* +exports.createFileOnScriptEngine('sdfdddddddddsfd','/tmp/testfile.txt', function(response){ + console.log("Success" + response) +}, function(response){ + console.log("Error" + response) +}); +*/ diff --git a/server/components/ssh/ssh2_exec.js b/server/components/ssh/ssh2_exec.js new file mode 100644 index 0000000..9894c4c --- /dev/null +++ b/server/components/ssh/ssh2_exec.js @@ -0,0 +1,108 @@ +var Client = require('ssh2').Client; + +//var exec = require('ssh-exec'); + +var config = require('../../config/environment'); + +exports.executeCommand = function(command, dataCallback,completeCallback,errorCallback, ansibleEngine, addScriptEndString){ + + /*var fs = require('filendir'); + var time = new Date().getTime(); + //var logFile = 'logs/deploy/' + logfilename; + var logFile = logfilelocation;*/ + + var conn = new Client(); + + if(!ansibleEngine) ansibleEngine = {}; + + var connHost = ansibleEngine.ansibleHost || config.scriptEngine.host; + var connUser = ansibleEngine.ansibleHostUser || config.scriptEngine.user; + var connHostPassword = ansibleEngine.ansibleHostPassword || config.scriptEngine.password; + + var scriptEngineConfig = { + host: connHost, + port: 22, + username: connUser, + tryKeyboard: true + }; + + if(connHostPassword){ + scriptEngineConfig.password = connHostPassword; + }else{ + scriptEngineConfig.privateKey = require('fs').readFileSync(config.scriptEngine.privateKey); + } + + //fs.appendFile(logFile,command); + //console.log("Writing Command to log file =" + command) + /*fs.writeFile(logFile,"\n",{'flag':'a'});*/ + + conn.on('keyboard-interactive', function(name, instr, lang, prompts, cb) { + cb([connHostPassword]); + }); + + conn.on('error', function(error) { + console.log("SSH Connect Error" + error); + errorCallback(error); + }); + + conn.on('ready', function() { + console.log('Client :: ready'); + console.log('Command :: ' + command); + conn.exec(command, function(err, stream) { + var callBackSent = false; + + var result_data = ""; + var error_data = ""; + var error = false; + + if (err) { + console.log("Error=" + err); + errorCallback(err); + + } + stream.on('close', function(code, signal) { + console.log('Stream :: close :: code: ' + code + ', signal: ' + signal); + //completeCallback('Stream :: close :: code: ' + code + ', signal: ' + signal + '\nSCRIPT_FINISHED'); + if(addScriptEndString){ + //dataCallback call is what writes to logfile + result_data += '\nSCRIPT_FINISHED'; + dataCallback(result_data); + } + + if(code !== 0){ + errorCallback(error_data) + }else{ + completeCallback(result_data) + } + conn.end(); + }).on('data', function(data) { + console.log('STDOUT: ' + data); + result_data += data; + if(dataCallback){ + //dataCallback(data); + dataCallback(result_data); + } + + }).stderr.on('data', function(data) { + console.log('STDERR: ' + data); + error_data += data; + error = true; + //errorCallback(data); + + }); + }); + }).connect(scriptEngineConfig); +}; + + +//exports.executeCommand(null,'python3.4 /data/ehc-builder/scripts/vipr/python/ehc-builder/scripts/bin/main.py vro all --inputfile="configure_vmware_vro_Mumshad_Mannambeth_1468092975124.in" --logfile="configure_vmware_vro_Mumshad_Mannambeth_1468092975124"', 'logs/deploy/configure_vmware_vro_Mumshad_Mannambeth_1468092975124.log' ) +/* + +exports.executeCommand(null,'date','testfile.log',function(response){ + console.log(response) +},function(response){ + console.log(response) +},function(response){ + console.log(response) +}) +*/ diff --git a/server/components/upgrade/upgradetool.js b/server/components/upgrade/upgradetool.js new file mode 100644 index 0000000..f3562e2 --- /dev/null +++ b/server/components/upgrade/upgradetool.js @@ -0,0 +1,116 @@ +/** + * Created by mannam4 on 7/31/2016. + */ +var ssh2_exec = require('../ssh/ssh2_exec'); +var config = require('../../config/environment'); + +exports.getLogs = function(logfilename,successCallback,errorCallback){ + + var logFile = '/opt/ehc-builder-scripts/logs/' + logfilename; + var command = 'cat ' +logFile; + + var logFileData = ''; + + console.log("Command = " + command); + + var localLogFile = 'logs/upgrade/upgrade.log'; + + ssh2_exec.executeCommand(command, + function(data){ + //Partial Data + //console.log("Data = "+ data) + //logFileData+=data + }, + function(data){ + //Complete Data + //console.log("Data =" + data) + if(data) + logFileData = (data.toString().replace('Stream :: close :: code: 0, signal: undefined','')); + console.log("Success Callback =" + logFileData); + successCallback(logFileData) + }, + function(error){ + //Error Data + //console.log("Error =" + error) + if(error) + logFileData+=error; + console.log("Error Callback =" + logFileData); + errorCallback(logFileData) + } + ); + + /*var logFile = 'logs/upgrade/' + logfilename; + var fs = require('fs'); + fs.readFile(logFile, function(err, data){ + if(err){ + errorCallback(err); + }else{ + successCallback(data); + } + + });*/ +}; + + +exports.upgrade = function(user,upgradeData,logfilename,dataCallback,completeCallback,errorCallback){ + var command = '/opt/ehc-builder-scripts/bin/ozone_upgrade.sh --force --restart > /opt/ehc-builder-scripts/logs/' + logfilename + " 2> >(sed $'s,.*,\\e[31m&\\e[m,'>&1)"; + + var logFile = 'logs/upgrade/' + logfilename; + + var fs = require('filendir'); + + fs.writeFile(logFile,command,{'flag':'a'}); + //return completeCallback(command); + + ssh2_exec.executeCommand(command, + function(data){ + //Partial Data + //console.log("Data = "+ data) + completeCallback(data) + }, + function(data){ + //Complete Data + //console.log("Data =" + data) + completeCallback(data) + }, + function(error){ + //Error Data + //console.log("Error =" + error) + errorCallback(error) + } + ) + +}; + + +exports.checkUpdates = function(user,dataCallback,completeCallback,errorCallback){ + var command = '/opt/ehc-builder-scripts/bin/check_updates.sh'; + + var logFile = 'logs/upgrade/check_updates.log'; + + var fs = require('filendir'); + + fs.writeFile(logFile,command,{'flag':'a'}); + //return completeCallback(command); + + console.log("Updates command " + command); + + ssh2_exec.executeCommand(command, + function(data){ + //Partial Data + //console.log("Data = "+ data) + dataCallback(data) + }, + function(data){ + //Complete Data + //console.log("Data =" + data) + completeCallback(data) + }, + function(error){ + //Error Data + //console.log("Error =" + error) + errorCallback(error) + } + ) + +}; diff --git a/server/components/utils/dbutility.js b/server/components/utils/dbutility.js new file mode 100644 index 0000000..1e6be03 --- /dev/null +++ b/server/components/utils/dbutility.js @@ -0,0 +1,31 @@ +var _ = require('underscore'); + +exports.updateDocument = function(doc, SchemaTarget, data) { + for (var field in SchemaTarget.schema.paths) { + if ((field !== '_id') && (field !== '__v')) { + var newValue = getObjValue(field, data); + if (newValue !== undefined) { + setObjValue(field, doc, newValue); + } + } + } + return doc; +}; + +function getObjValue(field, data) { + return _.reduce(field.split("."), function(obj, f) { + if(obj) return obj[f]; + }, data); +} + +function setObjValue(field, data, value) { + var fieldArr = field.split('.'); + return _.reduce(fieldArr, function(o, f, i) { + if(i == fieldArr.length-1) { + o[f] = value; + } else { + if(!o[f]) o[f] = {}; + } + return o[f]; + }, data); +} diff --git a/server/config/environment/development.js b/server/config/environment/development.js new file mode 100644 index 0000000..cb08353 --- /dev/null +++ b/server/config/environment/development.js @@ -0,0 +1,16 @@ +'use strict'; +/*eslint no-process-env:0*/ + +// Development specific configuration +// ================================== +module.exports = { + + // MongoDB connection options + mongo: { + uri: 'mongodb://db/app2-dev' + }, + + // Seed database on startup + seedDB: true + +}; diff --git a/server/config/environment/index.js b/server/config/environment/index.js new file mode 100644 index 0000000..189861c --- /dev/null +++ b/server/config/environment/index.js @@ -0,0 +1,66 @@ +'use strict'; +/*eslint no-process-env:0*/ + +import path from 'path'; +import _ from 'lodash'; + +/*function requiredProcessEnv(name) { + if(!process.env[name]) { + throw new Error('You must set the ' + name + ' environment variable'); + } + return process.env[name]; +}*/ + +// All configurations will extend these options +// ============================================ +var all = { + env: process.env.NODE_ENV, + + // Root path of server + root: path.normalize(`${__dirname}/../../..`), + + // Browser-sync port + browserSyncPort: process.env.BROWSER_SYNC_PORT || 3000, + + // Server port + port: process.env.PORT || 9000, + + // Server IP + ip: process.env.IP || '0.0.0.0', + + // Should we populate the DB with sample data? + seedDB: false, + + // Secret for session, you will want to change this and make it an environment variable + secrets: { + session: 'app2-secret' + }, + + // MongoDB connection options + mongo: { + options: { + db: { + safe: true + } + } + }, + + facebook: { + clientID: process.env.FACEBOOK_ID || 'id', + clientSecret: process.env.FACEBOOK_SECRET || 'secret', + callbackURL: `${process.env.DOMAIN || ''}/auth/facebook/callback` + }, + + google: { + clientID: process.env.GOOGLE_ID || 'id', + clientSecret: process.env.GOOGLE_SECRET || 'secret', + callbackURL: `${process.env.DOMAIN || ''}/auth/google/callback` + } +}; + +// Export the config object based on the NODE_ENV +// ============================================== +module.exports = _.merge( + all, + require('./shared'), + require(`./${process.env.NODE_ENV}.js`) || {}); diff --git a/server/config/environment/production.js b/server/config/environment/production.js new file mode 100644 index 0000000..6f85c5d --- /dev/null +++ b/server/config/environment/production.js @@ -0,0 +1,24 @@ +'use strict'; +/*eslint no-process-env:0*/ + +// Production specific configuration +// ================================= +module.exports = { + // Server IP + ip: process.env.OPENSHIFT_NODEJS_IP + || process.env.ip + || undefined, + + // Server port + port: process.env.OPENSHIFT_NODEJS_PORT + || process.env.PORT + || 8080, + + // MongoDB connection options + mongo: { + uri: process.env.MONGODB_URI + || process.env.MONGOHQ_URL + || process.env.OPENSHIFT_MONGODB_DB_URL + process.env.OPENSHIFT_APP_NAME + || 'mongodb://localhost/app2' + } +}; diff --git a/server/config/environment/shared.js b/server/config/environment/shared.js new file mode 100644 index 0000000..777032e --- /dev/null +++ b/server/config/environment/shared.js @@ -0,0 +1,11 @@ +'use strict'; + +exports = module.exports = { + // List of user roles + userRoles: ['guest', 'user', 'admin'], + 'scriptEngine' : { + 'host' : 'localhost', + 'user' : 'root', + 'password' : 'P@ssw0rd@123' + } +}; diff --git a/server/config/environment/test.js b/server/config/environment/test.js new file mode 100644 index 0000000..b748f8e --- /dev/null +++ b/server/config/environment/test.js @@ -0,0 +1,21 @@ +'use strict'; +/*eslint no-process-env:0*/ + +// Test specific configuration +// =========================== +module.exports = { + // MongoDB connection options + mongo: { + uri: 'mongodb://localhost/app2-test' + }, + sequelize: { + uri: 'sqlite://', + options: { + logging: false, + storage: 'test.sqlite', + define: { + timestamps: false + } + } + } +}; diff --git a/server/config/express.js b/server/config/express.js new file mode 100644 index 0000000..c4f938a --- /dev/null +++ b/server/config/express.js @@ -0,0 +1,133 @@ +/** + * Express configuration + */ + +'use strict'; + +import express from 'express'; +import favicon from 'serve-favicon'; +import morgan from 'morgan'; +import shrinkRay from 'shrink-ray'; +import bodyParser from 'body-parser'; +import methodOverride from 'method-override'; +import cookieParser from 'cookie-parser'; +import errorHandler from 'errorhandler'; +import path from 'path'; +import lusca from 'lusca'; +import config from './environment'; +import passport from 'passport'; +import session from 'express-session'; +import connectMongo from 'connect-mongo'; +import mongoose from 'mongoose'; +var MongoStore = connectMongo(session); + +export default function(app) { + var env = app.get('env'); + + if(env === 'development' || env === 'test') { + app.use(express.static(path.join(config.root, '.tmp'))); + } + + if(env === 'production') { + app.use(favicon(path.join(config.root, 'client', 'favicon.ico'))); + } + + app.set('appPath', path.join(config.root, 'client')); + app.use(express.static(app.get('appPath'))); + app.use(morgan('dev')); + + app.set('views', `${config.root}/server/views`); + app.engine('html', require('ejs').renderFile); + app.set('view engine', 'html'); + app.use(shrinkRay()); + app.use(bodyParser.urlencoded({ extended: false })); + app.use(bodyParser.json()); + app.use(methodOverride()); + app.use(cookieParser()); + app.use(passport.initialize()); + + + // Persist sessions with MongoStore / sequelizeStore + // We need to enable sessions for passport-twitter because it's an + // oauth 1.0 strategy, and Lusca depends on sessions + app.use(session({ + secret: config.secrets.session, + saveUninitialized: true, + resave: false, + store: new MongoStore({ + mongooseConnection: mongoose.connection, + db: 'app2' + }) + })); + + /** + * Lusca - express server security + * https://github.com/krakenjs/lusca + */ + if(env !== 'test' && !process.env.SAUCE_USERNAME) { + app.use(lusca({ + csrf: { + angular: true + }, + xframe: 'SAMEORIGIN', + hsts: { + maxAge: 31536000, //1 year, in seconds + includeSubDomains: true, + preload: true + }, + xssProtection: true + })); + } + + if(env === 'development') { + const webpackDevMiddleware = require('webpack-dev-middleware'); + const stripAnsi = require('strip-ansi'); + const webpack = require('webpack'); + const makeWebpackConfig = require('../../webpack.make'); + const webpackConfig = makeWebpackConfig({ DEV: true }); + const compiler = webpack(webpackConfig); + const browserSync = require('browser-sync').create(); + + /** + * Run Browsersync and use middleware for Hot Module Replacement + */ + browserSync.init({ + open: false, + logFileChanges: false, + proxy: `localhost:${config.port}`, + ws: true, + middleware: [ + webpackDevMiddleware(compiler, { + noInfo: false, + stats: { + colors: true, + timings: true, + chunks: false + } + }) + ], + port: config.browserSyncPort, + plugins: ['bs-fullscreen-message'] + }); + + /** + * Reload all devices when bundle is complete + * or send a fullscreen error message to the browser instead + */ + compiler.plugin('done', function(stats) { + console.log('webpack done hook'); + if(stats.hasErrors() || stats.hasWarnings()) { + return browserSync.sockets.emit('fullscreen:message', { + title: 'Webpack Error:', + body: stripAnsi(stats.toString()), + timeout: 100000 + }); + } + browserSync.reload(); + }); + } + + if(env === 'development' || env === 'test') { + app.use(errorHandler()); // Error handler - has to be last + } +} diff --git a/server/config/local.env.sample.js b/server/config/local.env.sample.js new file mode 100644 index 0000000..c929c39 --- /dev/null +++ b/server/config/local.env.sample.js @@ -0,0 +1,20 @@ +'use strict'; + +// Use local.env.js for environment variables that will be set when the server starts locally. +// Use for your api keys, secrets, etc. This file should not be tracked by git. +// +// You will need to set these on the server you deploy to. + +module.exports = { + DOMAIN: 'http://localhost:9000', + SESSION_SECRET: 'app2-secret', + + FACEBOOK_ID: 'app-id', + FACEBOOK_SECRET: 'secret', + + GOOGLE_ID: 'app-id', + GOOGLE_SECRET: 'secret', + + // Control debug level for modules using visionmedia/debug + DEBUG: '' +}; diff --git a/server/config/seed.js b/server/config/seed.js new file mode 100644 index 0000000..02a5b7f --- /dev/null +++ b/server/config/seed.js @@ -0,0 +1,66 @@ +/** + * Populate DB with sample data on server start + * to disable, edit config/environment/index.js, and set `seedDB: false` + */ + +'use strict'; +import Thing from '../api/thing/thing.model'; +import User from '../api/user/user.model'; +import config from './environment/'; + +export default function seedDatabaseIfNeeded() { + if(config.seedDB) { + Thing.find({}).remove() + .then(() => { + let thing = Thing.create({ + name: 'Development Tools', + info: 'Integration with popular tools such as Webpack, Gulp, Babel, TypeScript, Karma, ' + + 'Mocha, ESLint, Node Inspector, Livereload, Protractor, Pug, ' + + 'Stylus, Sass, and Less.' + }, { + name: 'Server and Client integration', + info: 'Built with a powerful and fun stack: MongoDB, Express, ' + + 'AngularJS, and Node.' + }, { + name: 'Smart Build System', + info: 'Build system ignores `spec` files, allowing you to keep ' + + 'tests alongside code. Automatic injection of scripts and ' + + 'styles into your index.html' + }, { + name: 'Modular Structure', + info: 'Best practice client and server structures allow for more ' + + 'code reusability and maximum scalability' + }, { + name: 'Optimized Build', + info: 'Build process packs up your templates as a single JavaScript ' + + 'payload, minifies your scripts/css/images, and rewrites asset ' + + 'names for caching.' + }, { + name: 'Deployment Ready', + info: 'Easily deploy your app to Heroku or Openshift with the heroku ' + + 'and openshift subgenerators' + }); + return thing; + }) + .then(() => console.log('finished populating things')) + .catch(err => console.log('error populating things', err)); + + User.find({}).remove() + .then(() => { + User.create({ + provider: 'local', + name: 'Test User', + email: 'test@example.com', + password: 'test' + }, { + provider: 'local', + role: 'admin', + name: 'Admin', + email: 'admin@example.com', + password: 'admin' + }) + .then(() => console.log('finished populating users')) + .catch(err => console.log('error populating users', err)); + }); + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..cc279f5 --- /dev/null +++ b/server/index.js @@ -0,0 +1,12 @@ +'use strict'; + +// Set default node environment to development +var env = process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +if(env === 'development' || env === 'test') { + // Register the Babel require hook + require('babel-register'); +} + +// Export the application +exports = module.exports = require('./app'); diff --git a/server/routes.js b/server/routes.js new file mode 100644 index 0000000..6cf7f0a --- /dev/null +++ b/server/routes.js @@ -0,0 +1,29 @@ +/** + * Main application routes + */ + +'use strict'; + +import errors from './components/errors'; +import path from 'path'; + +export default function(app) { + // Insert routes below + app.use('/api/custom_modules', require('./api/custom_module')); + app.use('/api/ansible', require('./api/ansible')); + app.use('/api/projects', require('./api/project')); + app.use('/api/things', require('./api/thing')); + app.use('/api/users', require('./api/user')); + + app.use('/auth', require('./auth').default); + + // All undefined asset or api routes should return a 404 + app.route('/:url(api|auth|components|app|bower_components|assets)/*') + .get(errors[404]); + + // All other routes should redirect to the index.html + app.route('/*') + .get((req, res) => { + res.sendFile(path.resolve(`${app.get('appPath')}/index.html`)); + }); +} diff --git a/server/views/404.html b/server/views/404.html new file mode 100644 index 0000000..ec98e3c --- /dev/null +++ b/server/views/404.html @@ -0,0 +1,157 @@ + + + + + Page Not Found :( + + + +
+

Not found :(

+

Sorry, but the page you were trying to view does not exist.

+

It looks like this was the result of either:

+
    +
  • a mistyped address
  • +
  • an out-of-date link
  • +
+ + +
+ + diff --git a/spec.js b/spec.js new file mode 100644 index 0000000..5b64e0c --- /dev/null +++ b/spec.js @@ -0,0 +1,12 @@ +'use strict'; +/*eslint-env node*/ +var testsContext; + +require('babel-polyfill'); +require('angular'); +require('angular-mocks'); +require('./client/components/ui-router/ui-router.mock'); + + +testsContext = require.context('./client', true, /\.spec\.js$/); +testsContext.keys().forEach(testsContext); diff --git a/webpack.build.js b/webpack.build.js new file mode 100644 index 0000000..2bf43c0 --- /dev/null +++ b/webpack.build.js @@ -0,0 +1,8 @@ +/** + * Webpack config for builds + */ +module.exports = require('./webpack.make')({ + BUILD: true, + TEST: false, + DEV: false +}); diff --git a/webpack.dev.js b/webpack.dev.js new file mode 100644 index 0000000..491f6e9 --- /dev/null +++ b/webpack.dev.js @@ -0,0 +1,8 @@ +/** + * Webpack config for development + */ +module.exports = require('./webpack.make')({ + BUILD: false, + TEST: false, + DEV: true +}); diff --git a/webpack.make.js b/webpack.make.js new file mode 100644 index 0000000..d5e603b --- /dev/null +++ b/webpack.make.js @@ -0,0 +1,363 @@ +'use strict'; +/*eslint-env node*/ +var webpack = require('webpack'); +var autoprefixer = require('autoprefixer'); +var HtmlWebpackPlugin = require('html-webpack-plugin'); +var HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); +var ExtractTextPlugin = require('extract-text-webpack-plugin'); +var CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin; +var fs = require('fs'); +var path = require('path'); +var ForkCheckerPlugin = require('awesome-typescript-loader').ForkCheckerPlugin; + +module.exports = function makeWebpackConfig(options) { + /** + * Environment type + * BUILD is for generating minified builds + * TEST is for generating test builds + */ + var BUILD = !!options.BUILD; + var TEST = !!options.TEST; + var E2E = !!options.E2E; + var DEV = !!options.DEV; + + /** + * Config + * Reference: http://webpack.github.io/docs/configuration.html + * This is the object where all configuration gets set + */ + var config = {}; + + /** + * Entry + * Reference: http://webpack.github.io/docs/configuration.html#entry + * Should be an empty object if it's generating a test build + * Karma will set this when it's a test build + */ + if(TEST) { + config.entry = {}; + } else { + config.entry = { + app: './client/app/app.js', + polyfills: './client/polyfills.js', + vendor: [ + 'angular', + 'angular-animate', + 'angular-aria', + 'angular-cookies', + 'angular-resource', + + 'angular-sanitize', + + 'angular-ui-bootstrap', + 'angular-ui-router', + 'lodash' + ] + }; + } + + /** + * Output + * Reference: http://webpack.github.io/docs/configuration.html#output + * Should be an empty object if it's generating a test build + * Karma will handle setting it up for you when it's a test build + */ + if(TEST) { + config.output = {}; + } else { + config.output = { + // Absolute output directory + path: BUILD ? path.join(__dirname, '/dist/client/') : path.join(__dirname, '/.tmp/'), + + // Output path from the view of the page + // Uses webpack-dev-server in development + publicPath: BUILD || DEV || E2E ? '/' : `http://localhost:${8080}/`, + //publicPath: BUILD ? '/' : 'http://localhost:' + env.port + '/', + + // Filename for entry points + // Only adds hash in build mode + filename: BUILD ? '[name].[hash].js' : '[name].bundle.js', + + // Filename for non-entry points + // Only adds hash in build mode + chunkFilename: BUILD ? '[name].[hash].js' : '[name].bundle.js' + }; + } + + + + if(TEST) { + config.resolve = { + modulesDirectories: [ + 'node_modules' + ], + extensions: ['', '.js', '.ts'] + }; + } + + /** + * Devtool + * Reference: http://webpack.github.io/docs/configuration.html#devtool + * Type of sourcemap to use per build type + */ + if(TEST) { + config.devtool = 'inline-source-map'; + } else if(BUILD || DEV) { + config.devtool = 'source-map'; + } else { + config.devtool = 'eval'; + } + + /** + * Loaders + * Reference: http://webpack.github.io/docs/configuration.html#module-loaders + * List: http://webpack.github.io/docs/list-of-loaders.html + * This handles most of the magic responsible for converting modules + */ + + + config.babel = { + shouldPrintComment(commentContents) { + // keep `/*@ngInject*/` + return /@ngInject/.test(commentContents); + } + } + + // Initialize module + config.module = { + noParse: /.*yaml\.min\.js/, + preLoaders: [ + { test: /\.json$/, loader: "json" } + ], + loaders: [{ + // JS LOADER + // Reference: https://github.com/babel/babel-loader + // Transpile .js files using babel-loader + // Compiles ES6 and ES7 into ES5 code + test: /\.js$/, + loader: 'babel', + include: [ + path.resolve(__dirname, 'client/'), + path.resolve(__dirname, 'node_modules/lodash-es/') + ] + }, { + // TS LOADER + // Reference: https://github.com/s-panferov/awesome-typescript-loader + // Transpile .ts files using awesome-typescript-loader + test: /\.ts$/, + loader: 'awesome-typescript-loader', + query: { + tsconfig: path.resolve(__dirname, 'tsconfig.client.json') + }, + include: [ + path.resolve(__dirname, 'client/') + ] + }, { + // ASSET LOADER + // Reference: https://github.com/webpack/file-loader + // Copy png, jpg, jpeg, gif, svg, woff, woff2, ttf, eot files to output + // Rename the file using the asset hash + // Pass along the updated reference to your code + // You can add here any file extension you want to get copied to your output + test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)([\?]?.*)$/, + loader: 'file' + }, { + + // HTML LOADER + // Reference: https://github.com/webpack/raw-loader + // Allow loading html through js + test: /\.html$/, + loader: 'raw' + }, { + // CSS LOADER + // Reference: https://github.com/webpack/css-loader + // Allow loading css through js + // + // Reference: https://github.com/postcss/postcss-loader + // Postprocess your css with PostCSS plugins + test: /\.css$/, + loader: !TEST + // Reference: https://github.com/webpack/extract-text-webpack-plugin + // Extract css files in production builds + // + // Reference: https://github.com/webpack/style-loader + // Use style-loader in development for hot-loading + ? ExtractTextPlugin.extract('style', 'css!postcss') + // Reference: https://github.com/webpack/null-loader + // Skip loading css in test mode + : 'null' + }] + }; + + config.module.postLoaders = [{ + test: /\.js$/, + loader: 'ng-annotate?single_quotes' + }]; + + // ISPARTA INSTRUMENTER LOADER + // Reference: https://github.com/ColCh/isparta-instrumenter-loader + // Instrument JS files with Isparta for subsequent code coverage reporting + // Skips node_modules and spec files + if(TEST) { + config.module.preLoaders.push({ + //delays coverage til after tests are run, fixing transpiled source coverage error + test: /\.js$/, + exclude: /(node_modules|spec\.js|mock\.js)/, + loader: 'isparta-instrumenter', + query: { + babel: { + // optional: ['runtime', 'es7.classProperties', 'es7.decorators'] + } + } + }); + } + + + /** + * PostCSS + * Reference: https://github.com/postcss/autoprefixer-core + * Add vendor prefixes to your css + */ + config.postcss = [ + autoprefixer({ + browsers: ['last 2 version'] + }) + ]; + + /** + * Plugins + * Reference: http://webpack.github.io/docs/configuration.html#plugins + * List: http://webpack.github.io/docs/list-of-plugins.html + */ + config.plugins = [ + /* + * Plugin: ForkCheckerPlugin + * Description: Do type checking in a separate process, so webpack don't need to wait. + * + * See: https://github.com/s-panferov/awesome-typescript-loader#forkchecker-boolean-defaultfalse + */ + new ForkCheckerPlugin(), + + // Reference: https://github.com/webpack/extract-text-webpack-plugin + // Extract css files + // Disabled when in test mode or not in build mode + new ExtractTextPlugin('[name].[hash].css', { + disable: !BUILD || TEST + }) + ]; + + if(!TEST) { + config.plugins.push(new CommonsChunkPlugin({ + name: 'vendor', + + // filename: "vendor.js" + // (Give the chunk a different name) + + minChunks: Infinity + // (with more entries, this ensures that no other module + // goes into the vendor chunk) + })); + } + + // Skip rendering index.html in test mode + // Reference: https://github.com/ampedandwired/html-webpack-plugin + // Render index.html + if(!TEST) { + let htmlConfig = { + template: 'client/_index.html', + filename: '../client/index.html', + alwaysWriteToDisk: true + } + config.plugins.push( + new HtmlWebpackPlugin(htmlConfig), + new HtmlWebpackHarddiskPlugin() + ); + } + + // Add build specific plugins + if(BUILD) { + config.plugins.push( + // Reference: http://webpack.github.io/docs/list-of-plugins.html#noerrorsplugin + // Only emit files when there are no errors + new webpack.NoErrorsPlugin(), + + // Reference: http://webpack.github.io/docs/list-of-plugins.html#dedupeplugin + // Dedupe modules in the output + new webpack.optimize.DedupePlugin(), + + // Reference: http://webpack.github.io/docs/list-of-plugins.html#uglifyjsplugin + // Minify all javascript, switch loaders to minimizing mode + new webpack.optimize.UglifyJsPlugin({ + mangle: false, + output: { + comments: false + }, + compress: { + warnings: false + } + }), + + // Reference: https://webpack.github.io/docs/list-of-plugins.html#defineplugin + // Define free global variables + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: '"production"' + } + }) + ); + } + + if(DEV) { + config.plugins.push( + // Reference: https://webpack.github.io/docs/list-of-plugins.html#defineplugin + // Define free global variables + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: '"development"' + } + }) + ); + } + + config.cache = DEV; + + if(TEST) { + config.stats = { + colors: true, + reasons: true + }; + config.debug = false; + } + + /** + * Dev server configuration + * Reference: http://webpack.github.io/docs/configuration.html#devserver + * Reference: http://webpack.github.io/docs/webpack-dev-server.html + */ + config.devServer = { + contentBase: './client/', + stats: { + modules: false, + cached: false, + colors: true, + chunk: false + } + }; + + config.node = { + global: 'window', + process: true, + crypto: 'empty', + clearImmediate: false, + setImmediate: false, + fs: "empty" + }; + + config.resolve = { + alias: { + "ace-builds": "../../node_modules/ace-builds/src-min-noconflict/ace.js" + } + }; + + return config; +}; diff --git a/webpack.test.js b/webpack.test.js new file mode 100644 index 0000000..9175a1b --- /dev/null +++ b/webpack.test.js @@ -0,0 +1,8 @@ +/** + * Webpack config for tests + */ +module.exports = require('./webpack.make')({ + BUILD: false, + TEST: true, + DEV: false +});