diff --git a/angular.json b/angular.json index dbe106cb45..b4f9cb83c6 100644 --- a/angular.json +++ b/angular.json @@ -28,6 +28,7 @@ ], "styles": [ "src/frontend/packages/core/src/styles.scss", + "src/frontend/packages/cf-autoscaler/src/styles.scss", "node_modules/xterm/dist/xterm.css" ], "scripts": [] @@ -114,7 +115,8 @@ } ], "styles": [ - "src/frontend/packages/core/src/styles.css" + "src/frontend/packages/core/src/styles.css", + "src/frontend/packages/cf-autoscaler/src/styles.css" ], "scripts": [] }, @@ -285,6 +287,37 @@ } } } + }, + "cf-autoscaler": { + "root": "src/frontend/packages/cf-autoscaler", + "sourceRoot": "src/frontend/packages/cf-autoscaler/src", + "projectType": "library", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-ng-packagr:build", + "options": { + "tsConfig": "src/frontend/packages/cf-autoscaler/tsconfig.lib.json", + "project": "src/frontend/packages/cf-autoscaler/ng-package.json" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/frontend/packages/cf-autoscaler/src/test.ts", + "tsConfig": "src/frontend/packages/cf-autoscaler/tsconfig.spec.json", + "karmaConfig": "src/frontend/packages/cf-autoscaler/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.json"], + "tslintConfig": "src/frontend/packages/cf-autoscaler/tslint.json", + "files": ["src/frontend/packages/cf-autoscaler/src/**/*.ts"] + } + } + } } }, "defaultProject": "stratos", diff --git a/package-lock.json b/package-lock.json index 4a95075ed4..883f5a5341 100644 --- a/package-lock.json +++ b/package-lock.json @@ -627,6 +627,11 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", @@ -1320,7 +1325,7 @@ }, "yargs": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-11.1.0.tgz", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-11.1.0.tgz", "integrity": "sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A==", "dev": true, "requires": { @@ -1351,7 +1356,7 @@ }, "yargs-parser": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.0.0.tgz", + "resolved": "http://registry.npmjs.org/yargs-parser/-/yargs-parser-10.0.0.tgz", "integrity": "sha512-+DHejWujTVYeMHLff8U96rLc4uE4Emncoftvn5AjhB1Jw1pWxLzgBUT/WYbPrHmy6YPEBTZQx5myHhVcuuu64g==", "dev": true, "requires": { @@ -1615,6 +1620,14 @@ "resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.6.5.tgz", "integrity": "sha512-6kBKf64aVfx93UJrcyEZ+OBM5nGv4RLsI6sR1Ar34bpgvGVRoyTgpxn4ZmtxOM5aDTAaaznYuYUH8bUX3Nk3YA==" }, + "@types/moment-timezone": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@types/moment-timezone/-/moment-timezone-0.5.12.tgz", + "integrity": "sha512-hnHH2+Efg2vExr/dSz+IX860nSiyk9Sk4pJF2EmS11lRpMcNXeB4KBW5xcgw2QPsb9amTXdsVNEe5IoJXiT0uw==", + "requires": { + "moment": "2.24.0" + } + }, "@types/node": { "version": "11.9.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-11.9.4.tgz", @@ -2005,7 +2018,6 @@ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", "dev": true, - "optional": true, "requires": { "kind-of": "3.2.2", "longest": "1.0.1", @@ -2017,7 +2029,6 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "dev": true, - "optional": true, "requires": { "is-buffer": "1.1.6" } @@ -2173,15 +2184,6 @@ "buffer-equal": "1.0.0" } }, - "append-transform": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", - "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", - "dev": true, - "requires": { - "default-require-extensions": "2.0.0" - } - }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -2615,15 +2617,7 @@ "babel-traverse": "6.26.0", "babel-types": "6.26.0", "babylon": "6.18.0", - "lodash": "4.17.10" - }, - "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", - "dev": true - } + "lodash": "4.17.13" } }, "babel-traverse": { @@ -2640,15 +2634,7 @@ "debug": "2.6.9", "globals": "9.18.0", "invariant": "2.2.4", - "lodash": "4.17.10" - }, - "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", - "dev": true - } + "lodash": "4.17.13" } }, "babel-types": { @@ -3628,15 +3614,7 @@ "integrity": "sha1-RYwH4J4NkA/Ci3Cj/sLazR0st/Y=", "dev": true, "requires": { - "lodash": "4.17.10" - }, - "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", - "dev": true - } + "lodash": "4.17.13" } }, "combined-stream": { @@ -3659,12 +3637,6 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, - "compare-versions": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.3.0.tgz", - "integrity": "sha512-MAAAIOdi2s4Gl6rZ76PNcUa9IOYB+5ICdT41o5uMRf09aEu/F9RK+qhe8RjXNPwcTjGV7KU7h2P/fljThFVqyQ==", - "dev": true - }, "component-bind": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", @@ -4537,23 +4509,6 @@ } } }, - "default-require-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", - "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", - "dev": true, - "requires": { - "strip-bom": "3.0.0" - }, - "dependencies": { - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - } - } - }, "default-resolution": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/default-resolution/-/default-resolution-2.0.0.tgz", @@ -5905,7 +5860,7 @@ "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", "dev": true, "requires": { - "homedir-polyfill": "^1.0.1" + "homedir-polyfill": "1.0.1" } }, "global-modules": { @@ -5914,9 +5869,9 @@ "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", "dev": true, "requires": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" + "global-prefix": "1.0.2", + "is-windows": "1.0.2", + "resolve-dir": "1.0.1" } }, "global-prefix": { @@ -5925,11 +5880,11 @@ "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=", "dev": true, "requires": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" + "expand-tilde": "2.0.2", + "homedir-polyfill": "1.0.1", + "ini": "1.3.5", + "is-windows": "1.0.2", + "which": "1.3.1" } }, "resolve-dir": { @@ -5938,8 +5893,8 @@ "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=", "dev": true, "requires": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" + "expand-tilde": "2.0.2", + "global-modules": "1.0.0" } } } @@ -5963,7 +5918,7 @@ "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", "dev": true, "requires": { - "homedir-polyfill": "^1.0.1" + "homedir-polyfill": "1.0.1" } } } @@ -6236,8 +6191,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -6255,13 +6209,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "1.0.0", "concat-map": "0.0.1" @@ -6274,18 +6226,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -6388,8 +6337,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -6399,7 +6347,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "1.0.1" } @@ -6412,20 +6359,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "1.1.11" } }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.3.5", "bundled": true, - "optional": true, "requires": { "safe-buffer": "5.1.2", "yallist": "3.0.3" @@ -6442,7 +6386,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -6515,8 +6458,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -6526,7 +6468,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1.0.2" } @@ -6602,8 +6543,7 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -6633,7 +6573,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "1.1.0", "is-fullwidth-code-point": "1.0.0", @@ -6651,7 +6590,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "2.1.1" } @@ -6690,13 +6628,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.0.3", - "bundled": true, - "optional": true + "bundled": true } } }, @@ -7037,9 +6973,9 @@ "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", "dev": true, "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wrap-ansi": "2.1.0" } }, "gulp-cli": { @@ -7080,19 +7016,19 @@ "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", "dev": true, "requires": { - "camelcase": "^3.0.0", - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^1.0.2", - "which-module": "^1.0.0", - "y18n": "^3.2.1", - "yargs-parser": "^5.0.0" + "camelcase": "3.0.0", + "cliui": "3.2.0", + "decamelize": "1.2.0", + "get-caller-file": "1.0.2", + "os-locale": "1.4.0", + "read-pkg-up": "1.0.1", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "1.0.2", + "which-module": "1.0.0", + "y18n": "3.2.1", + "yargs-parser": "5.0.0" } } } @@ -7131,12 +7067,12 @@ "dev": true }, "handlebars": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", - "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.1.tgz", + "integrity": "sha512-3Zhi6C0euYZL5sM0Zcy7lInLXKQ+YLcF/olbN010mzGQ4XVm50JeyBnMqofHh696GrciGruC7kCcApPDJvVgwA==", "dev": true, "requires": { - "async": "1.5.2", + "neo-async": "2.6.0", "optimist": "0.6.1", "source-map": "0.4.4", "uglify-js": "2.8.29" @@ -7789,6 +7725,11 @@ "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", "dev": true }, + "intersect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/intersect/-/intersect-1.0.1.tgz", + "integrity": "sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=" + }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -8240,7 +8181,7 @@ "escodegen": "1.8.1", "esprima": "2.7.3", "glob": "5.0.15", - "handlebars": "4.0.11", + "handlebars": "4.1.1", "js-yaml": "3.13.1", "mkdirp": "0.5.1", "nopt": "3.0.6", @@ -8294,16 +8235,15 @@ } }, "istanbul-api": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-1.3.1.tgz", - "integrity": "sha512-duj6AlLcsWNwUpfyfHt0nWIeRiZpuShnP40YTxOGQgtaN8fd6JYSxsvxUphTDy8V5MfDXo4s/xVCIIvVCO808g==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-1.3.7.tgz", + "integrity": "sha512-4/ApBnMVeEPG3EkSzcw25wDe4N66wxwn+KKn6b47vyek8Xb3NBAcg4xfuQbS7BqcZuTX4wxfD5lVagdggR3gyA==", "dev": true, "requires": { - "async": "2.6.1", - "compare-versions": "3.3.0", + "async": "2.6.2", "fileset": "2.0.3", - "istanbul-lib-coverage": "1.2.0", - "istanbul-lib-hook": "1.2.1", + "istanbul-lib-coverage": "1.2.1", + "istanbul-lib-hook": "1.2.2", "istanbul-lib-instrument": "1.10.1", "istanbul-lib-report": "1.1.4", "istanbul-lib-source-maps": "1.2.5", @@ -8313,21 +8253,56 @@ "once": "1.4.0" }, "dependencies": { + "append-transform": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-0.4.0.tgz", + "integrity": "sha1-126/jKlNJ24keja61EpLdKthGZE=", + "dev": true, + "requires": { + "default-require-extensions": "1.0.0" + } + }, "async": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", - "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz", + "integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==", "dev": true, "requires": { - "lodash": "4.17.10" - }, - "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", - "dev": true - } + "lodash": "4.17.13" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==" + }, + "default-require-extensions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz", + "integrity": "sha1-836hXT4T/9m0N9M+GnW1+5eHTLg=", + "dev": true, + "requires": { + "strip-bom": "2.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + }, + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.2.2.tgz", + "integrity": "sha512-/Jmq7Y1VeHnZEQ3TL10VHyb564mn6VrQXHchON9Jf/AEcmQ3ZIiyD1BVzNOKTZf/G3gE+kiGK6SmpF9y3qGPLw==", + "dev": true, + "requires": { + "append-transform": "0.4.0" } } } @@ -8373,15 +8348,6 @@ "integrity": "sha512-GvgM/uXRwm+gLlvkWHTjDAvwynZkL9ns15calTrmhGgowlwJBbWMYzWbKqE2DT6JDP1AFXKa+Zi0EkqNCUqY0A==", "dev": true }, - "istanbul-lib-hook": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.2.1.tgz", - "integrity": "sha512-eLAMkPG9FU0v5L02lIkcj/2/Zlz9OuluaXikdr5iStk8FDbSwAixTK9TkYxbF0eNnzAJTwM2fkV2A1tpsIp4Jg==", - "dev": true, - "requires": { - "append-transform": "1.0.0" - } - }, "istanbul-lib-instrument": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.1.tgz", @@ -8456,7 +8422,7 @@ "integrity": "sha512-y2Z2IMqE1gefWUaVjrBm0mSKvUkaBy9Vqz8iwr/r40Y9hBbIteH5wqHG/9DLTfJ9xUnUT2j7A3+VVJ6EaYBllA==", "dev": true, "requires": { - "handlebars": "4.0.11" + "handlebars": "4.1.1" } }, "jasmine": { @@ -8715,7 +8681,7 @@ "graceful-fs": "4.1.11", "http-proxy": "1.17.0", "isbinaryfile": "3.0.3", - "lodash": "4.17.10", + "lodash": "4.17.13", "log4js": "3.0.6", "mime": "2.4.0", "minimatch": "3.0.4", @@ -8730,23 +8696,112 @@ "useragent": "2.3.0" }, "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", + "async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz", + "integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==", + "requires": { + "lodash": "4.17.13" + } + }, + "circular-json": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.5.9.tgz", + "integrity": "sha512-4ivwqHpIFJZBuhN3g/pEcdbnGUywkBblloGbkglyloVjjR3uT6tieI89MVOfbP2tHX5sgb01FuLgAOzebNlJNQ==", "dev": true }, + "date-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.0.0.tgz", + "integrity": "sha512-M6UqVvZVgFYqZL1SfHsRGIQSz3ZL+qgbsV5Lp1Vj61LZVYuEwcMXYay7DRDtYs2HQQBK5hQtQ0fD9aEJ89V0LA==" + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "2.1.1" + } + }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "4.0.0", + "universalify": "0.1.1" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "4.1.11" + } + }, + "log4js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-3.0.6.tgz", + "integrity": "sha512-ezXZk6oPJCWL483zj64pNkMuY/NcRX5MPiB0zE6tjZM137aeusrOnW1ecxgF9cmwMWkBMhjteQxBPoZBh9FDxQ==", + "dev": true, + "requires": { + "circular-json": "0.5.9", + "date-format": "1.2.0", + "debug": "3.2.6", + "rfdc": "1.1.2", + "streamroller": "0.7.0" + }, + "dependencies": { + "date-format": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-1.2.0.tgz", + "integrity": "sha1-YV6CjiM90aubua4JUODOzPpuytg=", + "dev": true + }, + "streamroller": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-0.7.0.tgz", + "integrity": "sha512-WREzfy0r0zUqp3lGO096wRuUp7ho1X6uo/7DJfTlEi0Iv/4gT7YHqXDjKC2ioVGBZtE8QzsQD9nx1nIuoZ57jQ==", + "dev": true, + "requires": { + "date-format": "1.2.0", + "debug": "3.2.6", + "mkdirp": "0.5.1", + "readable-stream": "2.3.6" + } + } + } + }, "mime": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz", "integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==", "dev": true }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true + }, + "streamroller": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-1.0.3.tgz", + "integrity": "sha512-P7z9NwP51EltdZ81otaGAN3ob+/F88USJE546joNq7bqRNTe6jc74fTBDyynxP4qpIfKlt/CesEYicuMzI0yJg==", + "requires": { + "async": "2.6.2", + "date-format": "2.0.0", + "debug": "3.2.6", + "fs-extra": "7.0.1", + "lodash": "4.17.13" + } } } }, @@ -8775,7 +8830,7 @@ "integrity": "sha512-UcgrHkFehI5+ivMouD8NH/UOHiX4oCAtwaANylzPFdcAuD52fnCUuelacq2gh8tZ4ydhU3+xiXofSq7j5Ehygw==", "dev": true, "requires": { - "istanbul-api": "1.3.1", + "istanbul-api": "1.3.7", "minimatch": "3.0.4" } }, @@ -9056,8 +9111,7 @@ "lodash": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.13.tgz", - "integrity": "sha512-vm3/XWXfWtRua0FkUyEHBZy8kCPjErNBT9fJx8Zvs+U6zjqPbTUOpkaoum3O5uiA8sm+yNMHXfYkTUHFoMxFNA==", - "dev": true + "integrity": "sha512-vm3/XWXfWtRua0FkUyEHBZy8kCPjErNBT9fJx8Zvs+U6zjqPbTUOpkaoum3O5uiA8sm+yNMHXfYkTUHFoMxFNA==" }, "lodash-es": { "version": "4.17.14", @@ -9157,8 +9211,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true, - "optional": true + "dev": true }, "loose-envify": { "version": "1.3.1", @@ -9801,6 +9854,14 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" }, + "moment-timezone": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.23.tgz", + "integrity": "sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==", + "requires": { + "moment": "2.24.0" + } + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -10062,16 +10123,8 @@ }, "ngrx-store-localstorage": { "version": "github:cf-stratos/ngrx-store-localstorage#e722ed60861d49bec99482ac54f13e7485bb0ac9", - "from": "github:cf-stratos/ngrx-store-localstorage#e722ed60861d49bec99482ac54f13e7485bb0ac9", "requires": { - "lodash": "4.17.11" - }, - "dependencies": { - "lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" - } + "lodash": "4.17.13" } }, "ngx-moment": { @@ -10626,7 +10679,7 @@ }, "onetime": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", "dev": true }, @@ -13643,15 +13696,7 @@ "integrity": "sha512-NT0YGhwuQ0EOX+uPhhUcI6/+1Sq/pMzNuSCBVT4GbFl/ac6I/JZefBcjlECNfAb1t3GOx5dEj1Z7x0cAxeeVLQ==", "dev": true, "requires": { - "lodash": "4.17.10" - }, - "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", - "dev": true - } + "lodash": "4.17.13" } }, "statuses": { @@ -14164,7 +14209,7 @@ "dependencies": { "rimraf": { "version": "2.5.4", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz", + "resolved": "http://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz", "integrity": "sha1-loAAk8vxoMhr2VtGJUZ1NcKd+gQ=", "dev": true, "requires": { @@ -14940,8 +14985,7 @@ "universalify": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", - "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=", - "dev": true + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" }, "unpipe": { "version": "1.0.0", @@ -15733,15 +15777,7 @@ "integrity": "sha512-4p8WQyS98bUJcCvFMbdGZyZmsKuWjWVnVHnAS3FFg0HDaRVrPbkivx2RYCre8UiemD67RsiFFLfn4JhLAin8Vw==", "dev": true, "requires": { - "lodash": "4.17.10" - }, - "dependencies": { - "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", - "dev": true - } + "lodash": "4.17.13" } }, "webpack-sources": { diff --git a/package.json b/package.json index 5d71a98f3d..bf5e1aa9b8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test-frontend:core": "ng test core --code-coverage --watch=false", "test-frontend:store": "ng test store --code-coverage --watch=false", "test-frontend:cloud-foundry": "ng test cloud-foundry --code-coverage --watch=false", + "test-frontend:cf-autoscaler": "ng test cf-autoscaler --code-coverage --watch=false", "posttest": "istanbul report json && node build/combine-coverage.js", "codecov": "codecov -f coverage/coverage-final.json", "lint": "ng lint --format stylish", @@ -61,6 +62,7 @@ "@ngrx/store": "^7.2.0", "@ngrx/store-devtools": "^7.2.0", "@swimlane/ngx-charts": "^10.1.0", + "@types/moment-timezone": "^0.5.12", "@types/marked": "^0.6.5", "angular2-virtual-scroll": "^0.3.1", "core-js": "^2.6.5", @@ -68,7 +70,9 @@ "lodash-es": "^4.17.14", "mappy-breakpoints": "^0.2.3", "marked": "^0.7.0", + "intersect": "^1.0.1", "moment": "^2.24.0", + "moment-timezone": "^0.5.12", "ngrx-store-localstorage": "cf-stratos/ngrx-store-localstorage#lodash-dep-update", "ngx-moment": "^3.3.0", "normalizr": "^3.2.3", diff --git a/src/frontend/packages/cf-autoscaler/karma.conf.js b/src/frontend/packages/cf-autoscaler/karma.conf.js new file mode 100644 index 0000000000..aa31921f71 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/karma.conf.js @@ -0,0 +1,8 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + ...require('../../../../build/karma.conf.creator.js')('cf-autoscaler')(config) + }) +} diff --git a/src/frontend/packages/cf-autoscaler/ng-package.json b/src/frontend/packages/cf-autoscaler/ng-package.json new file mode 100644 index 0000000000..dc5592ade4 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../../dist/cf-autoscaler", + "lib": { + "entryFile": "src/public_api.ts" + } +} diff --git a/src/frontend/packages/cf-autoscaler/package.json b/src/frontend/packages/cf-autoscaler/package.json new file mode 100644 index 0000000000..5258e6ebc3 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/package.json @@ -0,0 +1,8 @@ +{ + "name": "cf-autoscaler", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^6.0.0-rc.0 || ^6.0.0", + "@angular/core": "^6.0.0-rc.0 || ^6.0.0" + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-testing.module.ts b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-testing.module.ts new file mode 100644 index 0000000000..cd4586dc0b --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-testing.module.ts @@ -0,0 +1,16 @@ +import { CfAutoscalerModule } from './cf-autoscaler.module'; +import { registerEntitiesForTesting } from '../../core/test-framework/store-test-helper'; +import { autoscalerEntities, AutoscalerStoreModule } from './store/autoscaler.store.module'; +import { NgModule } from '@angular/core'; + +@NgModule({ + imports: [ + AutoscalerStoreModule + ] +}) +export class CfAutoscalerTestingModule { + + constructor() { + registerEntitiesForTesting(autoscalerEntities); + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler.module.ts b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler.module.ts new file mode 100644 index 0000000000..8f4badd13e --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler.module.ts @@ -0,0 +1,43 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; +import { of } from 'rxjs'; + +import { CoreModule } from '../../core/src/core/core.module'; +import { MDAppModule } from '../../core/src/core/md.module'; +import { SharedModule } from '../../core/src/shared/shared.module'; +import { AutoscalerModule } from './core/autoscaler.module'; +import { AutoscalerTabExtensionComponent } from './features/autoscaler-tab-extension/autoscaler-tab-extension.component'; + +const customRoutes: Routes = [ + { + path: 'autoscaler', + loadChildren: './core/autoscaler.module#AutoscalerModule', + data: { + stratosNavigation: { + text: 'Applications', + matIcon: 'apps', + position: 20, + hidden: of(true) + } + }, + }, +]; + +@NgModule({ + imports: [ + CoreModule, + CommonModule, + SharedModule, + MDAppModule, + NgxChartsModule, + AutoscalerModule, + RouterModule.forRoot(customRoutes), + ], + declarations: [ + AutoscalerTabExtensionComponent + ], + entryComponents: [AutoscalerTabExtensionComponent] +}) +export class CfAutoscalerModule { } diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-metric.spec.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-metric.spec.ts new file mode 100644 index 0000000000..4ad5bb4cb2 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-metric.spec.ts @@ -0,0 +1,488 @@ +import { + isEqual +} from './autoscaler-util'; +import { + insertEmptyMetrics, buildMetricData +} from './autoscaler-transform-metric'; + +describe('Autoscaler Transform Metric Helper', () => { + it('insertEmptyMetrics', () => { + const descTarget = [20, 40, 60, 80, 100]; + const descSource1 = insertEmptyMetrics([], 100, 10, -20); + descSource1.map((item, index) => { + expect(isEqual(item.time, descTarget[index])).toBe(true); + }); + const descSource2 = insertEmptyMetrics([], 100, 20, -20); + descSource2.map((item, index) => { + expect(isEqual(item.time, descTarget[index])).toBe(true); + }); + const ascTarget = [10, 30, 50, 70, 90]; + const ascSource1 = insertEmptyMetrics([], 10, 90, 20); + ascSource1.map((item, index) => { + expect(isEqual(item.time, ascTarget[index])).toBe(true); + }); + const ascSource2 = insertEmptyMetrics([], 10, 100, 20); + ascSource2.map((item, index) => { + expect(isEqual(item.time, ascTarget[index])).toBe(true); + }); + }); + it('buildMetricData', () => { + const metricName = 'throughput'; + const data = { + total_results: 12, + total_pages: 1, + page: 1, + prev_url: null, + next_url: null, + resources: [ + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '6', + unit: 'rps', + timestamp: 1557026418641445600 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '5', + unit: 'rps', + timestamp: 1557026457387453200 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '5', + unit: 'rps', + timestamp: 1557026497600111000 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '5', + unit: 'rps', + timestamp: 1557026538904853200 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '6', + unit: 'rps', + timestamp: 1557026577882890500 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '8', + unit: 'rps', + timestamp: 1557026616931637000 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '12', + unit: 'rps', + timestamp: 1557026657849241600 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '18', + unit: 'rps', + timestamp: 1557026697503883000 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '21', + unit: 'rps', + timestamp: 1557026737224771000 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '18', + unit: 'rps', + timestamp: 1557026778997745700 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '16', + unit: 'rps', + timestamp: 1557026817953504800 + }, + { + app_id: '2bd98ff4-99f4-422a-a037-172298277c8b', + name: 'throughput', + value: '14', + unit: 'rps', + timestamp: 1557026857236819500 + } + ] + }; + const startTime = 1557026400000000000; + const endTime = 1557026880000000000; + const skipFormat = false; + const trigger = { + lower: [ + { + adjustment: '-1', + breach_duration_secs: 60, + cool_down_secs: 60, + metric_type: 'throughput', + operator: '<=', + threshold: 5, + color: 'rgba(51, 204, 255, 0.6)' + } + ], + upper: [ + { + adjustment: '+2', + breach_duration_secs: 60, + cool_down_secs: 60, + metric_type: 'throughput', + operator: '>', + threshold: 20, + color: 'rgba(255, 0, 0, 0.6)' + }, + { + adjustment: '+1', + breach_duration_secs: 60, + cool_down_secs: 60, + metric_type: 'throughput', + operator: '>', + threshold: 10, + color: 'rgba(255, 128, 0, 0.6)' + } + ], + query: { + metric: 'policy', + params: { + start: 1557026400, + end: 1557026880, + step: 9.6 + } + } + }; + const expectedResult = { + latest: { + target: [ + { + name: 'throughput', + value: 14 + } + ], + colorTarget: [ + { + name: 'throughput', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: 'upper threshold: > 20', + value: 'rgba(255, 0, 0, 0.6)' + }, + { + name: 'upper threshold: > 10', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: 'lower threshold: <= 5', + value: 'rgba(51, 204, 255, 0.6)' + } + ] + }, + formated: { + target: [ + { + time: 1557026419, + name: '03:20:19', + value: 6 + }, + { + time: 1557026458, + name: '03:20:58', + value: 5 + }, + { + time: 1557026497, + name: '03:21:37', + value: 5 + }, + { + time: 1557026536, + name: '03:22:16', + value: 5 + }, + { + time: 1557026575, + name: '03:22:55', + value: 6 + }, + { + time: 1557026614, + name: '03:23:34', + value: 8 + }, + { + time: 1557026653, + name: '03:24:13', + value: 12 + }, + { + time: 1557026692, + name: '03:24:52', + value: 18 + }, + { + time: 1557026731, + name: '03:25:31', + value: 21 + }, + { + time: 1557026770, + name: '03:26:10', + value: 18 + }, + { + time: 1557026809, + name: '03:26:49', + value: 16 + }, + { + time: 1557026848, + name: '03:27:28', + value: 14 + } + ], + colorTarget: [ + { + name: '03:20:19', + value: 'rgba(90,167,0,0.6)' + }, + { + name: '03:20:58', + value: 'rgba(51, 204, 255, 0.6)' + }, + { + name: '03:21:37', + value: 'rgba(51, 204, 255, 0.6)' + }, + { + name: '03:22:16', + value: 'rgba(51, 204, 255, 0.6)' + }, + { + name: '03:22:55', + value: 'rgba(90,167,0,0.6)' + }, + { + name: '03:23:34', + value: 'rgba(90,167,0,0.6)' + }, + { + name: '03:24:13', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: '03:24:52', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: '03:25:31', + value: 'rgba(255, 0, 0, 0.6)' + }, + { + name: '03:26:10', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: '03:26:49', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: '03:27:28', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: 'upper threshold: > 20', + value: 'rgba(255, 0, 0, 0.6)' + }, + { + name: 'upper threshold: > 10', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: 'lower threshold: <= 5', + value: 'rgba(51, 204, 255, 0.6)' + } + ] + }, + markline: [ + { + name: 'upper threshold: > 20', + series: [ + { + name: '03:20:19', + value: 20 + }, + { + name: '03:20:58', + value: 20 + }, + { + name: '03:21:37', + value: 20 + }, + { + name: '03:22:16', + value: 20 + }, + { + name: '03:22:55', + value: 20 + }, + { + name: '03:23:34', + value: 20 + }, + { + name: '03:24:13', + value: 20 + }, + { + name: '03:24:52', + value: 20 + }, + { + name: '03:25:31', + value: 20 + }, + { + name: '03:26:10', + value: 20 + }, + { + name: '03:26:49', + value: 20 + }, + { + name: '03:27:28', + value: 20 + } + ] + }, + { + name: 'upper threshold: > 10', + series: [ + { + name: '03:20:19', + value: 10 + }, + { + name: '03:20:58', + value: 10 + }, + { + name: '03:21:37', + value: 10 + }, + { + name: '03:22:16', + value: 10 + }, + { + name: '03:22:55', + value: 10 + }, + { + name: '03:23:34', + value: 10 + }, + { + name: '03:24:13', + value: 10 + }, + { + name: '03:24:52', + value: 10 + }, + { + name: '03:25:31', + value: 10 + }, + { + name: '03:26:10', + value: 10 + }, + { + name: '03:26:49', + value: 10 + }, + { + name: '03:27:28', + value: 10 + } + ] + }, + { + name: 'lower threshold: <= 5', + series: [ + { + name: '03:20:19', + value: 5 + }, + { + name: '03:20:58', + value: 5 + }, + { + name: '03:21:37', + value: 5 + }, + { + name: '03:22:16', + value: 5 + }, + { + name: '03:22:55', + value: 5 + }, + { + name: '03:23:34', + value: 5 + }, + { + name: '03:24:13', + value: 5 + }, + { + name: '03:24:52', + value: 5 + }, + { + name: '03:25:31', + value: 5 + }, + { + name: '03:26:10', + value: 5 + }, + { + name: '03:26:49', + value: 5 + }, + { + name: '03:27:28', + value: 5 + } + ] + } + ], + unit: 'rps', + chartMaxValue: 30 + }; + const result = buildMetricData(metricName, data, startTime, endTime, skipFormat, trigger, 'UTC'); + expect(isEqual(expectedResult, result)).toBe(true); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-metric.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-metric.ts new file mode 100644 index 0000000000..88b6e9cb64 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-metric.ts @@ -0,0 +1,276 @@ +import * as moment from 'moment-timezone'; + +import { PaginationResponse } from '../../../../store/src/types/api.types'; +import { + AppAutoscalerMetricBasicInfo, + AppAutoscalerMetricData, + AppAutoscalerMetricDataLine, + AppAutoscalerMetricDataLocal, + AppAutoscalerMetricDataPoint, + AppScalingRule, + AppScalingTrigger, +} from '../../store/app-autoscaler.types'; +import { AutoscalerConstants, getScaleType } from './autoscaler-util'; + +function initMetricData(metricName: string): AppAutoscalerMetricDataLocal { + return { + latest: { + target: [{ + name: metricName, + value: 0 + }], + colorTarget: [ + { + name: metricName, + value: 'rgba(0,0,0,0.2)' + } + ] + }, + formated: { + target: [], + colorTarget: [] + }, + markline: [], + unit: AutoscalerConstants.metricMap[metricName].unit_internal, + chartMaxValue: 0, + }; +} + +export function buildMetricData( + metricName: string, + data: PaginationResponse, + startTime: number, + endTime: number, + skipFormat: boolean, + trigger: AppScalingTrigger, + timezone?: string +): AppAutoscalerMetricDataLocal { + const result = initMetricData(metricName); + if (data.resources.length > 0) { + const basicInfo = getMetricBasicInfo(metricName, data.resources, trigger); + result.unit = basicInfo.unit; + result.chartMaxValue = basicInfo.chartMaxValue; + if (!skipFormat) { + startTime = Math.round(startTime / AutoscalerConstants.S2NS); + endTime = Math.round(endTime / AutoscalerConstants.S2NS); + const target = transformMetricData(data.resources, basicInfo.interval, startTime, endTime, timezone); + const colorTarget = buildMetricColorData(target, trigger); + result.formated = { + target, + colorTarget + }; + result.markline = buildMarkLineData(target, trigger); + } + result.latest.target = [{ + name: metricName, + value: Number(data.resources[data.resources.length - 1].value) + }]; + result.latest.colorTarget = buildMetricColorData(result.latest.target, trigger); + } + return result; +} + +export function insertEmptyMetrics( + data: AppAutoscalerMetricDataPoint[], + startTime: number, + endTime: number, + interval: number, + timezone?: string +): AppAutoscalerMetricDataPoint[] { + const insertEmptyNumber = Math.floor((endTime - startTime) / interval) + 1; + for (let i = 0; i < insertEmptyNumber; i++) { + const emptyMetric = buildSingleMetricData(startTime + i * interval, 0, timezone); + if (interval < 0) { + data.unshift(emptyMetric); + } else { + data.push(emptyMetric); + } + } + return data; +} + +function buildSingleMetricData(timestamp: number, value: number | string, timezone: string): AppAutoscalerMetricDataPoint { + const name = (() => { + if (timezone) { + return moment(timestamp * 1000).tz(timezone).format(AutoscalerConstants.MomentFormateTimeS); + } else { + return moment(timestamp * 1000).format(AutoscalerConstants.MomentFormateTimeS); + } + })(); + return { + time: timestamp, + name, + value + }; +} + +function transformMetricData( + source: AppAutoscalerMetricData[], + interval: number, + startTime: number, + endTime: number, + timezone: string): AppAutoscalerMetricDataPoint[] { + if (source.length === 0) { + return []; + } + const scope = Math.round(interval / 2); + const target: AppAutoscalerMetricDataPoint[] = []; + let targetTimestamp = Math.round(source[0].timestamp / AutoscalerConstants.S2NS); + let targetIndex = insertEmptyMetrics(target, targetTimestamp - interval, startTime, -interval, timezone).length; + let sourceIndex = 0; + while (sourceIndex < source.length) { + if (!target[targetIndex]) { + target[targetIndex] = buildSingleMetricData(targetTimestamp, 0, timezone); + } + const metric = source[sourceIndex]; + const sourceTimestamp = Math.round(metric.timestamp / AutoscalerConstants.S2NS); + if (sourceTimestamp < targetTimestamp - scope) { + sourceIndex++; + } else if (sourceTimestamp > targetTimestamp + scope) { + targetIndex++; + targetTimestamp += interval; + } else { + target[targetIndex].value = Number(metric.value); + sourceIndex++; + } + } + return insertEmptyMetrics(target, target[targetIndex].time + interval, endTime, interval, timezone); +} + +function buildMetricColorData(metricData: AppAutoscalerMetricDataPoint[], trigger: AppScalingTrigger): AppAutoscalerMetricDataPoint[] { + const colorTarget: AppAutoscalerMetricDataPoint[] = []; + metricData.map((item) => { + colorTarget.push({ + name: item.name, + value: getColor(trigger, item.value), + }); + }); + if (trigger.upper.length > 0) { + buildSingleColor(colorTarget, trigger.upper); + } + if (trigger.lower.length > 0) { + buildSingleColor(colorTarget, trigger.lower); + } + return colorTarget; +} + +function buildSingleColor(lineChartSeries: AppAutoscalerMetricDataPoint[], ul: AppScalingRule[]) { + ul.forEach((item) => { + const lineData = { + name: buildTriggerName(item), + value: item.color + }; + lineChartSeries.push(lineData); + }); +} + +function buildMarkLineData(metricData: AppAutoscalerMetricDataPoint[], trigger: AppScalingTrigger): AppAutoscalerMetricDataLine[] { + return buildSingleMarkLine(metricData, trigger.upper).concat(buildSingleMarkLine(metricData, trigger.lower)); +} + +function buildTriggerName(item: AppScalingRule): string { + const type = getScaleType(item.operator); + return `${type} threshold: ${item.operator} ${item.threshold}`; +} + +function buildSingleMarkLine(metricData: AppAutoscalerMetricDataPoint[], ul: AppScalingRule[]) { + return ul.reduce((lineChartSeries, item) => { + const lineData = { + name: buildTriggerName(item), + series: metricData.map((data) => { + return { + name: data.name, + value: item.threshold, + }; + }) + }; + lineChartSeries.push(lineData); + return lineChartSeries; + }, []); +} + +function executeCompare(val1: number, operator: string, val2: number): boolean { + switch (operator) { + case '>': + return val1 > val2; + case '>=': + return val1 >= val2; + case '<': + return val1 < val2; + default: + return val1 <= val2; + } +} + +function getColorCommon(triggerRules: AppScalingRule[], value: number, isLower?: boolean) { + for (let i = 0; triggerRules && triggerRules.length > 0 && i < triggerRules.length; i++) { + const index = isLower ? triggerRules.length - 1 - i : i; + if (executeCompare(value, triggerRules[index].operator, triggerRules[index].threshold)) { + return triggerRules[index].color; + } + } + return ''; +} + +function getColor(trigger: AppScalingTrigger, value: any): string { + if (Number.isNaN(value)) { + return AutoscalerConstants.normalColor; + } + return getColorCommon(trigger.upper, value) || getColorCommon(trigger.lower, value, true) || AutoscalerConstants.normalColor; +} + +function getMetricBasicInfo( + metricName: string, + source: AppAutoscalerMetricData[], + trigger: AppScalingTrigger +): AppAutoscalerMetricBasicInfo { + const intervalMap = {}; + let maxCount = 1; + let preTimestamp = 0; + let maxValue = -1; + const unit = AutoscalerConstants.metricMap[metricName].unit_internal; + intervalMap[AutoscalerConstants.metricMap[metricName].interval] = 1; + const resultInterval = source.reduce((interval, item) => { + maxValue = Math.max(Number(item.value), maxValue); + const thisTimestamp = Math.round(item.timestamp / AutoscalerConstants.S2NS); + const currentInterval = thisTimestamp - preTimestamp; + intervalMap[currentInterval] = intervalMap[currentInterval] ? intervalMap[currentInterval] + 1 : 1; + if (intervalMap[currentInterval] > maxCount) { + interval = currentInterval; + maxCount = intervalMap[currentInterval]; + } + preTimestamp = thisTimestamp; + // unit = item.unit === '' ? unit : item.unit; + return interval; + }, AutoscalerConstants.metricMap[metricName].interval); + return { + interval: resultInterval, + unit, + chartMaxValue: getChartMax(trigger, maxValue, metricName) + }; +} + +function getMaxThreshod(rules: AppScalingRule[]) { + return rules.length > 0 ? rules[0].threshold : 0; +} + +function getChartMax(trigger: AppScalingTrigger, maxValue: number, metricName: string): number { + if (AutoscalerConstants.MetricPercentageTypes.indexOf(metricName) >= 0) { + return 100; + } + const thresholdCount = trigger.upper.length + trigger.lower.length; + const maxThreshold = Math.max(getMaxThreshod(trigger.upper), getMaxThreshod(trigger.lower)); + const thresholdmax = Math.ceil(maxThreshold * (thresholdCount + 1) / (thresholdCount)); + return getTrimmedInteger(Math.max(maxValue, thresholdmax, 10)); +} + +function getTrimmedInteger(thresholdmax: number): number { + for (let i = 10; i < Number.MAX_VALUE && i < thresholdmax; i = i * 10) { + if (thresholdmax / i >= 1 && thresholdmax / i < 10 && thresholdmax > 100) { + return (Math.ceil(thresholdmax / i * 10)) * i / 10; + } else if (thresholdmax / i >= 1 && thresholdmax / i < 10 && thresholdmax <= 100) { + return (Math.ceil(thresholdmax / i)) * i; + } + } + return thresholdmax; +} diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-policy.spec.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-policy.spec.ts new file mode 100644 index 0000000000..9512064caf --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-policy.spec.ts @@ -0,0 +1,329 @@ +import { isEqual } from './autoscaler-util'; +import { + autoscalerTransformArrayToMap, + autoscalerTransformMapToArray, +} from './autoscaler-transform-policy'; +import { AppAutoscalerPolicy, AppAutoscalerPolicyLocal } from '../../store/app-autoscaler.types'; + +describe('Autoscaler Transform Policy Helper', () => { + it('Test policy transformation', () => { + const arrayPolicy: AppAutoscalerPolicy = { + instance_min_count: 1, + instance_max_count: 10, + scaling_rules: [ + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 10, + operator: '<=', + cool_down_secs: 300, + adjustment: '-2' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 30, + operator: '<', + cool_down_secs: 300, + adjustment: '-1' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 120, + operator: '>', + cool_down_secs: 300, + adjustment: '+3' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 90, + operator: '>=', + cool_down_secs: 300, + adjustment: '+2' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 200, + operator: '>=', + cool_down_secs: 300, + adjustment: '+4' + }, + { + metric_type: 'memoryutil', + breach_duration_secs: 600, + threshold: 20, + operator: '<', + cool_down_secs: 300, + adjustment: '-3' + }, + { + metric_type: 'responsetime', + breach_duration_secs: 600, + threshold: 50, + operator: '>=', + cool_down_secs: 300, + adjustment: '+4' + }, + { + metric_type: 'responsetime', + breach_duration_secs: 600, + threshold: 40, + operator: '<', + cool_down_secs: 300, + adjustment: '-5' + }, + { + metric_type: 'memoryutil', + breach_duration_secs: 600, + threshold: 90, + operator: '>=', + cool_down_secs: 300, + adjustment: '+6' + } + ], + schedules: { + timezone: 'Asia/Shanghai', + recurring_schedule: [ + { + start_time: '10:00', + end_time: '18:00', + days_of_week: [ + 1, + 2, + 3 + ], + instance_min_count: 1, + instance_max_count: 10, + initial_min_instance_count: 5 + }, + { + start_date: '2099-06-27', + end_date: '2099-07-23', + start_time: '11:00', + end_time: '19:30', + days_of_month: [ + 5, + 15, + 25 + ], + instance_min_count: 3, + instance_max_count: 10 + } + ], + specific_date: [ + { + start_date_time: '2099-06-02T10:00', + end_date_time: '2099-06-15T13:59', + instance_min_count: 1, + instance_max_count: 4, + initial_min_instance_count: 2 + }, + { + start_date_time: '2099-01-04T20:00', + end_date_time: '2099-02-19T23:15', + instance_min_count: 2, + instance_max_count: 5 + } + ] + } + }; + const { ...mapPolicy }: AppAutoscalerPolicyLocal = { + ...arrayPolicy, + enabled: true, + scaling_rules_map: { + memoryused: { + lower: [ + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 30, + operator: '<', + cool_down_secs: 300, + adjustment: '-1', + color: 'rgba(51, 204, 255, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 10, + operator: '<=', + cool_down_secs: 300, + adjustment: '-2', + color: 'rgba(51, 136, 255, 0.6)' + } + ], + upper: [ + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 200, + operator: '>=', + cool_down_secs: 300, + adjustment: '+4', + color: 'rgba(255, 0, 0, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 120, + operator: '>', + cool_down_secs: 300, + adjustment: '+3', + color: 'rgba(255, 85, 0, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 90, + operator: '>=', + cool_down_secs: 300, + adjustment: '+2', + color: 'rgba(255, 170, 0, 0.6)' + } + ] + }, + memoryutil: { + lower: [ + { + metric_type: 'memoryutil', + breach_duration_secs: 600, + threshold: 20, + operator: '<', + cool_down_secs: 300, + adjustment: '-3', + color: 'rgba(51, 204, 255, 0.6)' + } + ], + upper: [ + { + metric_type: 'memoryutil', + breach_duration_secs: 600, + threshold: 90, + operator: '>=', + cool_down_secs: 300, + adjustment: '+6', + color: 'rgba(255, 0, 0, 0.6)' + } + ] + }, + responsetime: { + upper: [ + { + metric_type: 'responsetime', + breach_duration_secs: 600, + threshold: 50, + operator: '>=', + cool_down_secs: 300, + adjustment: '+4', + color: 'rgba(255, 0, 0, 0.6)' + } + ], + lower: [ + { + metric_type: 'responsetime', + breach_duration_secs: 600, + threshold: 40, + operator: '<', + cool_down_secs: 300, + adjustment: '-5', + color: 'rgba(51, 204, 255, 0.6)' + } + ] + } + }, + scaling_rules_form: [ + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 200, + operator: '>=', + cool_down_secs: 300, + adjustment: '+4', + color: 'rgba(255, 0, 0, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 120, + operator: '>', + cool_down_secs: 300, + adjustment: '+3', + color: 'rgba(255, 85, 0, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 90, + operator: '>=', + cool_down_secs: 300, + adjustment: '+2', + color: 'rgba(255, 170, 0, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 30, + operator: '<', + cool_down_secs: 300, + adjustment: '-1', + color: 'rgba(51, 204, 255, 0.6)' + }, + { + metric_type: 'memoryused', + breach_duration_secs: 600, + threshold: 10, + operator: '<=', + cool_down_secs: 300, + adjustment: '-2', + color: 'rgba(51, 136, 255, 0.6)' + }, + { + metric_type: 'memoryutil', + breach_duration_secs: 600, + threshold: 90, + operator: '>=', + cool_down_secs: 300, + adjustment: '+6', + color: 'rgba(255, 0, 0, 0.6)' + }, + { + metric_type: 'memoryutil', + breach_duration_secs: 600, + threshold: 20, + operator: '<', + cool_down_secs: 300, + adjustment: '-3', + color: 'rgba(51, 204, 255, 0.6)' + }, + { + metric_type: 'responsetime', + breach_duration_secs: 600, + threshold: 50, + operator: '>=', + cool_down_secs: 300, + adjustment: '+4', + color: 'rgba(255, 0, 0, 0.6)' + }, + { + metric_type: 'responsetime', + breach_duration_secs: 600, + threshold: 40, + operator: '<', + cool_down_secs: 300, + adjustment: '-5', + color: 'rgba(51, 204, 255, 0.6)' + } + ] + }; + const mapPolicyFromArray = autoscalerTransformArrayToMap(arrayPolicy); + const arrayPolicyFromMap = autoscalerTransformMapToArray(mapPolicy); + expect(isEqual(mapPolicyFromArray, mapPolicy)).toBe(true); + expect(isEqual(arrayPolicyFromMap, autoscalerTransformMapToArray(autoscalerTransformArrayToMap(arrayPolicy)))).toBe(true); + delete arrayPolicy.scaling_rules; + mapPolicy.scaling_rules_form = []; + expect(isEqual(arrayPolicy, autoscalerTransformMapToArray(mapPolicy))).toBe(true); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-policy.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-policy.ts new file mode 100644 index 0000000000..ef37ce3d6d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-transform-policy.ts @@ -0,0 +1,152 @@ +import * as moment from 'moment-timezone'; + +import { + AppAutoscalerPolicy, + AppAutoscalerPolicyLocal, + AppScalingRule, + AppScalingTrigger, +} from '../../store/app-autoscaler.types'; +import { AutoscalerConstants, getScaleType, isEqual } from './autoscaler-util'; + +export function autoscalerTransformArrayToMap(policy: AppAutoscalerPolicy) { + const newPolicy: AppAutoscalerPolicyLocal = { + ...policy, + enabled: true, + scaling_rules_map: {}, + scaling_rules_form: [] + }; + newPolicy.scaling_rules = newPolicy.scaling_rules || []; + newPolicy.scaling_rules.map((trigger) => { + pushAndSortTrigger(newPolicy.scaling_rules_map, trigger.metric_type, trigger); + }); + let maxThreshold = 0; + Object.keys(newPolicy.scaling_rules_map).map((metricName) => { + if (newPolicy.scaling_rules_map[metricName].upper && newPolicy.scaling_rules_map[metricName].upper.length > 0) { + maxThreshold = newPolicy.scaling_rules_map[metricName].upper[0].threshold; + setUpperColor(newPolicy.scaling_rules_map[metricName].upper); + } + if (newPolicy.scaling_rules_map[metricName].lower && newPolicy.scaling_rules_map[metricName].lower.length > 0) { + maxThreshold = Math.max(newPolicy.scaling_rules_map[metricName].lower[0].threshold, maxThreshold); + setLowerColor(newPolicy.scaling_rules_map[metricName].lower); + } + buildFormUponMap(newPolicy, metricName); + }); + newPolicy.schedules = newPolicy.schedules || { timezone: moment.tz.guess() }; + newPolicy.schedules.recurring_schedule = newPolicy.schedules.recurring_schedule || []; + newPolicy.schedules.specific_date = newPolicy.schedules.specific_date || []; + return newPolicy; +} + +function setUpperColor(array: AppScalingRule[]) { + // from FF0000 to FFFF00 + // from rgba(255,0,0,0.6) to rgba(255,255,0,0.6) + const max = 255; // parseInt('FF', 16) + const min = 0; // parseInt('00', 16) + const scope = max - min; + if (array && array.length > 0) { + const interval = Math.round(scope / array.length); + for (let i = 0; i < array.length; i++) { + let color10 = 0 + i * interval; + if (color10 > max) { + color10 = max; + } + // let color16 = color10.toString(16) + // if (color16.length === 1) color16 = '0' + color16 + array[i].color = 'rgba(255, ' + color10 + ', 0, 0.6)'; // '#ff' + color16 + '00' + } + } +} + +function setLowerColor(array: AppScalingRule[]) { + // from 3344ff to 33ccff + // from rgba(51,68,255,0.6) to rgba(51,204,255,0.6) + const max = 204; // parseInt('CC', 16) + const min = 68; // parseInt('44', 16) + const scope = max - min; + if (array && array.length > 0) { + const interval = Math.round(scope / array.length); + for (let i = 0; i < array.length; i++) { + let color10 = max - i * interval; + if (color10 < min) { + color10 = min; + } + // let color16 = color10.toString(16) + // if (color16.length === 1) color16 = '0' + color16 + array[i].color = 'rgba(51, ' + color10 + ', 255, 0.6)'; // '#33' + color16 + 'ff' + } + } +} + +export function autoscalerTransformMapToArray(oldPolicy: AppAutoscalerPolicyLocal) { + const newPolicy: AppAutoscalerPolicy = { + instance_min_count: oldPolicy.instance_min_count, + instance_max_count: oldPolicy.instance_max_count + }; + const scalingRules: AppScalingRule[] = oldPolicy.scaling_rules_form.map((trigger) => { + return { + adjustment: trigger.adjustment, + breach_duration_secs: trigger.breach_duration_secs, + cool_down_secs: trigger.breach_duration_secs, + metric_type: trigger.metric_type, + operator: trigger.operator, + threshold: trigger.threshold + }; + }); + if (scalingRules.length > 0) { + newPolicy.scaling_rules = scalingRules; + } + if (oldPolicy.schedules && + (hasNamedSchedule(oldPolicy.schedules.recurring_schedule) || hasNamedSchedule(oldPolicy.schedules.specific_date))) { + newPolicy.schedules = { + timezone: oldPolicy.schedules.timezone + }; + if (hasNamedSchedule(oldPolicy.schedules.recurring_schedule)) { + newPolicy.schedules.recurring_schedule = oldPolicy.schedules.recurring_schedule; + } + if (hasNamedSchedule(oldPolicy.schedules.specific_date)) { + newPolicy.schedules.specific_date = oldPolicy.schedules.specific_date; + } + } + return newPolicy; +} + +function hasNamedSchedule(schedule: any) { + return schedule !== undefined && schedule !== null && schedule.length > 0; +} + +function pushAndSortTrigger(map: { [metricName: string]: AppScalingTrigger }, metricName: string, newTrigger: AppScalingRule) { + const scaleType = getScaleType(newTrigger.operator); + newTrigger.breach_duration_secs = + newTrigger.breach_duration_secs || AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_default; + newTrigger.cool_down_secs = newTrigger.cool_down_secs || AutoscalerConstants.PolicyDefaultSetting.cool_down_secs_default; + if (!map[metricName]) { + map[metricName] = { + upper: [], + lower: [] + }; + } + if (!map[metricName][scaleType]) { + map[metricName][scaleType] = []; + } + for (let i = 0; i < map[metricName][scaleType].length; i++) { + if (newTrigger.threshold > map[metricName][scaleType][i].threshold) { + map[metricName][scaleType].splice(i, 0, newTrigger); + return; + } + } + map[metricName][scaleType].push(newTrigger); +} + +function buildFormUponMap(newPolicy: AppAutoscalerPolicyLocal, metricName: string) { + AutoscalerConstants.ScaleTypes.forEach((triggerType) => { + if (newPolicy.scaling_rules_map[metricName][triggerType]) { + newPolicy.scaling_rules_map[metricName][triggerType].forEach((trigger: AppScalingRule) => { + newPolicy.scaling_rules_form.push(trigger); + }); + } + }); +} + +export function isPolicyMapEqual(a: AppAutoscalerPolicyLocal, b: AppAutoscalerPolicyLocal) { + return isEqual(autoscalerTransformMapToArray(a), autoscalerTransformMapToArray(b)); +} diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts new file mode 100644 index 0000000000..9d62652ac1 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts @@ -0,0 +1,73 @@ +import { + isEqual, buildLegendData, shiftArray +} from './autoscaler-util'; + +describe('Autoscaler Util Helper', () => { + it('buildLegendData', () => { + const trigger = { + lower: [ + { + adjustment: '-1', + breach_duration_secs: 60, + cool_down_secs: 60, + metric_type: 'throughput', + operator: '<=', + threshold: 5, + color: 'rgba(51, 204, 255, 0.6)' + } + ], + upper: [ + { + adjustment: '+2', + breach_duration_secs: 60, + cool_down_secs: 60, + metric_type: 'throughput', + operator: '>', + threshold: 20, + color: 'rgba(255, 0, 0, 0.6)' + }, + { + adjustment: '+1', + breach_duration_secs: 60, + cool_down_secs: 60, + metric_type: 'throughput', + operator: '>', + threshold: 10, + color: 'rgba(255, 128, 0, 0.6)' + } + ], + query: { + metric: 'policy', + params: { + start: 1557026400, + end: 1557026880, + step: 9.6 + } + } + }; + const expectedLegend = [ + { + name: 'throughput > 20', + value: 'rgba(255, 0, 0, 0.6)' + }, + { + name: '10 < throughput <= 20', + value: 'rgba(255, 128, 0, 0.6)' + }, + { + name: '5 < throughput <= 10', + value: 'rgba(90,167,0,0.6)' + }, + { + name: 'throughput <= 5', + value: 'rgba(51, 204, 255, 0.6)' + } + ]; + const legend = buildLegendData(trigger); + expect(isEqual(expectedLegend, legend)).toBe(true); + }); + it('shiftArray', () => { + expect(shiftArray([0, 2, 3], 1)).toEqual([1, 3, 4]); + expect(shiftArray([1, 6, 9], -1)).toEqual([0, 5, 8]); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts new file mode 100644 index 0000000000..46fd4b6d21 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts @@ -0,0 +1,269 @@ +import * as moment from 'moment-timezone'; + +import { + AppAutoscalerMetricDataPoint, + AppAutoscalerMetricLegend, + AppAutoscalerMetricMapInfo, + AppScalingRule, + AppScalingTrigger, +} from '../../store/app-autoscaler.types'; + + +export class AutoscalerConstants { + public static S2NS = 1000000000; + public static MetricTypes = ['memoryused', 'memoryutil', 'responsetime', 'throughput', 'cpu']; + public static MetricPercentageTypes = ['memoryutil']; + public static ScaleTypes = ['upper', 'lower']; + public static UpperOperators = ['>', '>=']; + public static LowerOperators = ['<', '<=']; + public static WeekdayOptions = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + public static MonthdayOptions = (() => { + const days = []; + for (let i = 0; i < 31; i++) { + days[i] = i + 1; + } + return days; + })(); + + public static normalColor = 'rgba(90,167,0,0.6)'; + public static MomentFormateDate = 'YYYY-MM-DD'; + public static MomentFormateDateTimeT = 'YYYY-MM-DDTHH:mm'; + public static MomentFormateTime = 'HH:mm'; + public static MomentFormateTimeS = 'HH:mm:ss'; + + public static PolicyDefaultSetting = { + breach_duration_secs_default: 120, + breach_duration_secs_min: 60, + breach_duration_secs_max: 3600, + cool_down_secs_default: 300, + cool_down_secs_min: 60, + cool_down_secs_max: 3600, + }; + public static PolicyDefaultTrigger = { + metric_type: 'memoryused', + breach_duration_secs: AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_default, + threshold: 10, + operator: '<=', + cool_down_secs: AutoscalerConstants.PolicyDefaultSetting.cool_down_secs_default, + adjustment: '-1' + }; + public static PolicyDefaultRecurringSchedule = { + start_time: '10:00', + end_time: '18:00', + days_of_week: [ + 1, 2, 3 + ], + instance_min_count: 1, + instance_max_count: 10, + initial_min_instance_count: 5 + }; + public static PolicyDefaultSpecificDate = { + start_date_time: moment().add(1, 'days').set('hour', 10).set('minute', 0).format(AutoscalerConstants.MomentFormateDateTimeT), + end_date_time: moment().add(1, 'days').set('hour', 18).set('minute', 0).format(AutoscalerConstants.MomentFormateDateTimeT), + instance_min_count: 1, + instance_max_count: 10, + initial_min_instance_count: 5 + }; + + public static metricMap: { [metricName: string]: AppAutoscalerMetricMapInfo } = { + memoryused: { + unit_internal: 'MB', + interval: 40, + }, + memoryutil: { + unit_internal: ' % ', + interval: 40, + }, + responsetime: { + unit_internal: 'ms', + interval: 40, + }, + throughput: { + unit_internal: 'rps', + interval: 40, + }, + cpu: { + unit_internal: ' % ', + interval: 40, + } + }; + + public static getMetricUnit(metricType: string) { + if (AutoscalerConstants.metricMap[metricType]) { + return AutoscalerConstants.metricMap[metricType].unit_internal; + } else { + return ''; + } + } + + public static createMetricId(appGuid: string, metricType: string): string { + return appGuid + ':' + metricType; + } + + public static getMetricFromMetricId(metricId: string): string { + return metricId.slice(metricId.indexOf(':') + 1, metricId.length); + } +} + +export const PolicyAlert = { + alertInvalidPolicyMinimumRange: 'The Minimum Instance Count must be a integer less than the Maximum Instance Count.', + alertInvalidPolicyMaximumRange: 'The Maximum Instance Count must be a integer greater than the Minimum Instance Count.', + alertInvalidPolicyInitialMaximumRange: + 'The Initial Minimum Instance Count must be a integer in the range of Minimum Instance Count to Maximum Instance Count.', + alertInvalidPolicyTriggerUpperThresholdRange: 'The Upper Threshold value must be an integer greater than the Lower Threshold value.', + alertInvalidPolicyTriggerLowerThresholdRange: 'The Lower Threshold value must be an integer in the range of 1 to (Upper Threshold-1).', + alertInvalidPolicyTriggerThreshold100: 'The Lower/Upper Threshold value of memoryutil must be an integer below or equal to 100.', + alertInvalidPolicyTriggerStepPercentageRange: 'The Instance Step Up/Down percentage must be a integer greater than 1.', + alertInvalidPolicyTriggerStepRange: 'The Instance Step Up/Down value must be a integer in the range of 1 to (Maximum Instance-1).', + alertInvalidPolicyTriggerBreachDurationRange: + `The breach duration value must be an integer in the range of ${AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_min} to + ${AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_max} seconds.`, + alertInvalidPolicyTriggerCooldownRange: + `The cooldown period value must be an integer in the range of ${AutoscalerConstants.PolicyDefaultSetting.cool_down_secs_min} to + ${AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_max} seconds.`, + alertInvalidPolicyScheduleDateBeforeNow: 'Start/End date should be after or equal to current date.', + alertInvalidPolicyScheduleEndDateBeforeStartDate: 'Start date must be earlier than the end date.', + alertInvalidPolicyScheduleEndTimeBeforeStartTime: 'Start time must be earlier than the end time.', + alertInvalidPolicyScheduleRepeatOn: 'Please select at least one "Repeat On" day.', + alertInvalidPolicyScheduleEndDateTimeBeforeStartDateTime: 'Start date and time must be earlier than the end date and time.', + alertInvalidPolicyScheduleStartDateTimeBeforeNow: 'Start date and time must be after or equal to current date time.', + alertInvalidPolicyScheduleEndDateTimeBeforeNow: 'End date and time must be after or equal the current date and time.', + alertInvalidPolicyScheduleRecurringConflict: 'Recurring schedule configuration conflict occurs.', + alertInvalidPolicyScheduleSpecificConflict: 'Specific date configuration conflict occurs.', + alertInvalidPolicyTriggerScheduleEmpty: 'At least one Scaling Rule or Schedule should be defined.', +}; + +export function isEqual(a: any, b: any): boolean { + if (typeof a !== typeof b) { + return false; + } else { + if (typeof a === 'object') { + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + let equal = true; + Object.keys(a).map((key) => { + equal = equal && isEqual(a[key], b[key]); + }); + return equal; + } else { + return JSON.stringify(a) === JSON.stringify(b); + } + } +} + +export function getScaleType(operator: string): string { + if (AutoscalerConstants.LowerOperators.indexOf(operator) >= 0) { + return 'lower'; + } else { + return 'upper'; + } +} + +export function getAdjustmentType(adjustment: string): string { + return adjustment.indexOf('%') >= 0 ? 'percentage' : 'value'; +} + +export function buildLegendData(trigger: AppScalingTrigger): AppAutoscalerMetricLegend[] { + const legendData: AppAutoscalerMetricLegend[] = []; + let latestUl: AppScalingRule = null; + if (trigger.upper && trigger.upper.length > 0) { + const noLowerRule = !trigger.lower || trigger.lower.length === 0; + latestUl = buildUpperLegendData(legendData, trigger.upper, noLowerRule); + } + if (trigger.lower && trigger.lower.length > 0) { + latestUl = buildLowerLegendData(legendData, trigger.lower, latestUl); + } + return legendData; +} + +function getLegendName(currentRule: AppScalingRule, latestRule: AppScalingRule, singleRange: boolean, isLowerRule: boolean) { + if (singleRange) { + const operator = isLowerRule ? getOppositeOperator(currentRule.operator) : currentRule.operator; + return `${currentRule.metric_type} ${operator} ${currentRule.threshold}`; + } else { + return `${currentRule.threshold} ${getLeftOperator(currentRule.operator)} ${currentRule. + metric_type} ${getRightOperator(latestRule.operator)} ${latestRule.threshold}`; + } +} + +function buildUpperLegendData(legendData: any, upper: AppScalingRule[], noLower: boolean): AppScalingRule { + let latestUl: AppScalingRule; + upper.forEach((item, index) => { + const name = getLegendName(item, latestUl, index === 0, false); + legendData.push({ + name, + value: item.color + }); + latestUl = item; + }); + if (noLower) { + legendData.push({ + name: `${upper[0].metric_type} ${getOppositeOperator(latestUl.operator)} ${latestUl.threshold}`, + value: AutoscalerConstants.normalColor + }); + } + return latestUl; +} + +function buildLowerLegendData( + legendData: AppAutoscalerMetricDataPoint[], + lower: AppScalingRule[], + latestUl: AppScalingRule +): AppScalingRule { + lower.forEach((item, index) => { + const isSingleRange = !latestUl || !latestUl.threshold; + const name = getLegendName(item, latestUl, isSingleRange, true); + legendData.push({ + name, + value: index === 0 ? AutoscalerConstants.normalColor : latestUl.color + }); + latestUl = item; + }); + legendData.push({ + name: `${lower[0].metric_type} ${latestUl.operator} ${latestUl.threshold}`, + value: latestUl.color + }); + return latestUl; +} + +function getOppositeOperator(operator: string): string { + switch (operator) { + case '>': + return '<='; + case '>=': + return '<'; + case '<': + return '>='; + default: + return '>'; + } +} + +function getRightOperator(operator: string): string { + switch (operator) { + case '>': + return '<='; + case '>=': + return '<'; + default: + return operator; + } +} + +function getLeftOperator(operator: string): string { + switch (operator) { + case '>': + return '<'; + case '>=': + return '<='; + case '<': + return '<='; + default: + return '<'; + } +} + +export function shiftArray(array: number[], step: number): number[] { + return array.map(value => value + step); +} diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-validation.spec.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-validation.spec.ts new file mode 100644 index 0000000000..90c63b9e4e --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-validation.spec.ts @@ -0,0 +1,115 @@ +import { AppRecurringSchedule, AppSpecificDate } from '../../store/app-autoscaler.types'; +import { + dateIsAfter, + dateTimeIsSameOrAfter, + numberWithFractionOrExceedRange, + recurringSchedulesInvalidRepeatOn, + recurringSchedulesOverlapping, + specificDateRangeOverlapping, + timeIsSameOrAfter, +} from './autoscaler-validation'; + +describe('Autoscaler Util Helper', () => { + it('numberWithFractionOrExceedRange', () => { + expect(numberWithFractionOrExceedRange(undefined, 1, 10, false)).toBe(false); + expect(numberWithFractionOrExceedRange(undefined, 1, 10, true)).toBe(true); + expect(numberWithFractionOrExceedRange(5, 1, 10, true)).toBe(false); + expect(numberWithFractionOrExceedRange(1, 5, 10, true)).toBe(true); + }); + it('timeIsSameOrAfter', () => { + expect(timeIsSameOrAfter('10:00', '12:00')).toBe(false); + expect(timeIsSameOrAfter('10:00', '10:00')).toBe(true); + expect(timeIsSameOrAfter('10:00', '08:00')).toBe(true); + }); + it('dateIsAfter', () => { + expect(dateIsAfter('2020-01-01', '2020-01-02')).toBe(false); + expect(dateIsAfter('2020-01-01', '2020-01-01')).toBe(false); + expect(dateIsAfter('2020-01-01', '2019-12-31')).toBe(true); + }); + it('dateTimeIsSameOrAfter', () => { + expect(dateTimeIsSameOrAfter('2020-01-01 10:00', '2020-01-01 12:00')).toBe(false); + expect(dateTimeIsSameOrAfter('2020-01-01 10:00', '2020-01-01 10:00')).toBe(true); + expect(dateTimeIsSameOrAfter('2020-01-01 10:00', '2020-01-01 08:00')).toBe(true); + }); + it('recurringSchedulesInvalidRepeatOn', () => { + const recurring1: AppRecurringSchedule = { + days_of_month: [1, 2], + days_of_week: [3, 4], + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }; + const recurring2: AppRecurringSchedule = { + days_of_week: [3, 4], + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }; + const recurring3: AppRecurringSchedule = { + days_of_month: [1, 2], + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }; + const recurring4: AppRecurringSchedule = { + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }; + expect(recurringSchedulesInvalidRepeatOn(recurring1)).toBe(true); + expect(recurringSchedulesInvalidRepeatOn(recurring2)).toBe(false); + expect(recurringSchedulesInvalidRepeatOn(recurring3)).toBe(false); + expect(recurringSchedulesInvalidRepeatOn(recurring4)).toBe(true); + }); + it('recurringSchedulesOverlapping', () => { + const recurring1: AppRecurringSchedule = { + days_of_week: [1, 2], + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }; + const recurring2: AppRecurringSchedule = { + days_of_month: [3, 4], + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }; + const recurrings: AppRecurringSchedule[] = [{ + days_of_month: [4, 5], + instance_min_count: 1, + instance_max_count: 2, + start_time: '10:00', + end_time: '18:00' + }, recurring1, recurring2]; + expect(recurringSchedulesOverlapping(recurring1, 1, recurrings, 'days_of_week')).toBe(false); + expect(recurringSchedulesOverlapping(recurring2, 2, recurrings, 'days_of_month')).toBe(true); + }); + it('specificDateRangeOverlapping', () => { + const specific: AppSpecificDate = { + instance_min_count: 1, + instance_max_count: 2, + start_date_time: '2020-01-01T10:00', + end_date_time: '2020-01-01T20:00' + }; + const specifics1: AppSpecificDate[] = [{ + instance_min_count: 1, + instance_max_count: 2, + start_date_time: '2020-01-01T08:00', + end_date_time: '2020-01-01T18:00' + }, specific]; + const specifics2: AppSpecificDate[] = [{ + instance_min_count: 1, + instance_max_count: 2, + start_date_time: '2020-02-01T08:00', + end_date_time: '2020-02-01T18:00' + }, specific]; + expect(specificDateRangeOverlapping(specific, 1, specifics1)).toBe(true); + expect(specificDateRangeOverlapping(specific, 1, specifics2)).toBe(false); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-validation.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-validation.ts new file mode 100644 index 0000000000..2f9f60de3f --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-validation.ts @@ -0,0 +1,135 @@ +import * as intersect from 'intersect'; +import * as moment from 'moment-timezone'; + +import { AppRecurringSchedule, AppScalingRule, AppSpecificDate } from '../../store/app-autoscaler.types'; +import { AutoscalerConstants } from './autoscaler-util'; + +export function numberWithFractionOrExceedRange(value: any, min: number, max: number, required: boolean) { + if ((!value || isNaN(value)) && !required) { + return false; + } + if ((!value || isNaN(value)) && required) { + return true; + } + return value.toString().indexOf('.') > -1 || value > max || value < min; +} + +export function timeIsSameOrAfter(startTime: string, endTime: string) { + const startTimeMoment = moment('2000-01-01T' + startTime, AutoscalerConstants.MomentFormateDateTimeT); + const endTimeMoment = moment('2000-01-01T' + endTime, AutoscalerConstants.MomentFormateDateTimeT); + return startTimeMoment.isSameOrAfter(endTimeMoment); +} + +export function dateIsAfter(startDate: string, endDate: string) { + return moment(startDate, AutoscalerConstants.MomentFormateDate).isAfter(moment(endDate, AutoscalerConstants.MomentFormateDate)); +} + +export function dateTimeIsSameOrAfter(startDateTime: string, endDateTime: string) { + return moment(startDateTime).isSameOrAfter(moment(endDateTime)); +} + +export function recurringSchedulesInvalidRepeatOn(inputRecurringSchedules: AppRecurringSchedule) { + const weekdayCount = inputRecurringSchedules.hasOwnProperty('days_of_week') ? inputRecurringSchedules.days_of_week.length : 0; + const monthdayCount = inputRecurringSchedules.hasOwnProperty('days_of_month') ? inputRecurringSchedules.days_of_month.length : 0; + return (weekdayCount > 0 && monthdayCount > 0) || (weekdayCount === 0 && monthdayCount === 0); +} + +export function recurringSchedulesOverlapping( + newSchedule: AppRecurringSchedule, index: number, + inputRecurringSchedules: AppRecurringSchedule[], property: string) { + if (!inputRecurringSchedules) { + return false; + } + const overlappingSchedule = inputRecurringSchedules.find((value, i) => { + if (index === i || !inputRecurringSchedules[i].hasOwnProperty(property) || + inputRecurringSchedules[i].start_date && newSchedule.start_date && !dateOverlaps(inputRecurringSchedules[i], newSchedule)) { + return false; + } + if (timeOverlaps(inputRecurringSchedules[i], newSchedule)) { + const intersects = intersect(inputRecurringSchedules[i][property], newSchedule[property]); + return intersects.length > 0; + } + }); + return !!overlappingSchedule; +} + +export function specificDateRangeOverlapping(newSchedule: AppSpecificDate, index: number, inputSpecificDates: AppSpecificDate[]) { + const start = moment(newSchedule.start_date_time, AutoscalerConstants.MomentFormateDateTimeT); + const end = moment(newSchedule.end_date_time, AutoscalerConstants.MomentFormateDateTimeT); + if (inputSpecificDates) { + const dateRangeList = inputSpecificDates.map((value, i) => { + if (i !== index) { + const starti = moment(value.start_date_time, AutoscalerConstants.MomentFormateDateTimeT); + const endi = moment(value.end_date_time, AutoscalerConstants.MomentFormateDateTimeT); + return { + start: starti, + end: endi + }; + } + }); + const overlappingSchedule = dateRangeList.find((item) => { + if (item && dateTimeOverlaps(start, end, item.start, item.end)) { + return true; + } + }); + return !!overlappingSchedule; + } else { + return false; + } +} + +function timeOverlaps(timeI: AppRecurringSchedule, tiemJ: AppRecurringSchedule) { + const startDateTimeI = moment('1970-01-01T' + timeI.start_time, AutoscalerConstants.MomentFormateDateTimeT); + const endDateTimeI = moment('1970-01-01T' + timeI.end_time, AutoscalerConstants.MomentFormateDateTimeT); + const startDateTimeJ = moment('1970-01-01T' + tiemJ.start_time, AutoscalerConstants.MomentFormateDateTimeT); + const endDateTimeJ = moment('1970-01-01T' + tiemJ.end_time, AutoscalerConstants.MomentFormateDateTimeT); + return dateTimeOverlaps(startDateTimeI, endDateTimeI, startDateTimeJ, endDateTimeJ); +} + +function dateOverlaps(dateI: AppRecurringSchedule, dateJ: AppRecurringSchedule) { + const startDateTimeI = moment(dateI.start_date + 'T00:00', AutoscalerConstants.MomentFormateDateTimeT); + const endDateTimeI = moment(dateI.end_date + 'T23:59', AutoscalerConstants.MomentFormateDateTimeT); + const startDateTimeJ = moment(dateJ.start_date + 'T00:00', AutoscalerConstants.MomentFormateDateTimeT); + const endDateTimeJ = moment(dateJ.end_date + 'T23:59', AutoscalerConstants.MomentFormateDateTimeT); + return dateTimeOverlaps(startDateTimeI, endDateTimeI, startDateTimeJ, endDateTimeJ); +} + +function dateTimeOverlaps( + startDateTimeI: moment.Moment, endDateTimeI: moment.Moment, + startDateTimeJ: moment.Moment, endDateTimeJ: moment.Moment) { + if (startDateTimeJ.isAfter(startDateTimeI)) { + return endDateTimeI.isAfter(startDateTimeJ); + } else { + return endDateTimeJ.isAfter(startDateTimeI); + } +} + +export function getThresholdMin(policyTriggers: AppScalingRule[], metricType: string, scaleType: string, index: number) { + if (scaleType === 'upper') { + return policyTriggers.reduce((thresholdMin, trigger, triggerIndex) => { + if (triggerIndex !== index && trigger.metric_type === metricType && + AutoscalerConstants.LowerOperators.indexOf(trigger.operator) >= 0) { + return Math.max(trigger.threshold + 1, thresholdMin); + } else { + return thresholdMin; + } + }, 1); + } else { + return 1; + } +} + +export function getThresholdMax(policyTriggers: AppScalingRule[], metricType: string, scaleType: string, index: number) { + if (scaleType === 'lower') { + return policyTriggers.reduce((thresholdMax, trigger, triggerIndex) => { + if (triggerIndex !== index && trigger.metric_type === metricType && + AutoscalerConstants.UpperOperators.indexOf(trigger.operator) >= 0) { + return Math.min(trigger.threshold - 1, thresholdMax); + } else { + return thresholdMax; + } + }, Number.MAX_VALUE); + } else { + return Number.MAX_VALUE; + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler.module.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler.module.ts new file mode 100644 index 0000000000..ff6272debd --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler.module.ts @@ -0,0 +1,96 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { CoreModule } from '../../../core/src/core/core.module'; +import { ApplicationService } from '../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../core/src/shared/shared.module'; +import { AutoscalerBaseComponent } from '../features/autoscaler-base.component'; +import { AutoscalerMetricPageComponent } from '../features/autoscaler-metric-page/autoscaler-metric-page.component'; +import { + AutoscalerScaleHistoryPageComponent, +} from '../features/autoscaler-scale-history-page/autoscaler-scale-history-page.component'; +import { + EditAutoscalerPolicyStep1Component, +} from '../features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component'; +import { + EditAutoscalerPolicyStep2Component, +} from '../features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component'; +import { + EditAutoscalerPolicyStep3Component, +} from '../features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component'; +import { + EditAutoscalerPolicyStep4Component, +} from '../features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component'; +import { EditAutoscalerPolicyComponent } from '../features/edit-autoscaler-policy/edit-autoscaler-policy.component'; +import { CardAutoscalerDefaultComponent } from '../shared/card-autoscaler-default/card-autoscaler-default.component'; +import { + TableCellAutoscalerEventChangeIconPipe, +} from '../shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change-icon.pipe'; +import { + TableCellAutoscalerEventChangeComponent, +} from '../shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component'; +import { + TableCellAutoscalerEventStatusIconPipe, +} from '../shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status-icon.pipe'; +import { + TableCellAutoscalerEventStatusComponent, +} from '../shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component'; +import { + AppAutoscalerMetricChartCardComponent, +} from '../shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component'; +import { + AppAutoscalerComboChartComponent, +} from '../shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component'; +import { + AppAutoscalerComboSeriesVerticalComponent, +} from '../shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component'; +import { AutoscalerEffects } from '../store/autoscaler.effects'; +import { AutoscalerStoreModule } from '../store/autoscaler.store.module'; +import { AutoscalerRoutingModule } from './autoscaler.routing'; + + +@NgModule({ + imports: [ + CoreModule, + SharedModule, + AutoscalerRoutingModule, + AutoscalerStoreModule, + NgxChartsModule, + EffectsModule.forFeature([ + AutoscalerEffects + ]) + ], + declarations: [ + AutoscalerBaseComponent, + AutoscalerMetricPageComponent, + AutoscalerScaleHistoryPageComponent, + EditAutoscalerPolicyComponent, + EditAutoscalerPolicyStep1Component, + EditAutoscalerPolicyStep2Component, + EditAutoscalerPolicyStep3Component, + EditAutoscalerPolicyStep4Component, + CardAutoscalerDefaultComponent, + AppAutoscalerMetricChartCardComponent, + AppAutoscalerComboChartComponent, + AppAutoscalerComboSeriesVerticalComponent, + TableCellAutoscalerEventChangeComponent, + TableCellAutoscalerEventStatusComponent, + TableCellAutoscalerEventStatusIconPipe, + TableCellAutoscalerEventChangeIconPipe, + ], + exports: [ + CardAutoscalerDefaultComponent + ], + providers: [ + ApplicationService + ], + entryComponents: [ + AppAutoscalerMetricChartCardComponent, + AppAutoscalerComboChartComponent, + AppAutoscalerComboSeriesVerticalComponent, + TableCellAutoscalerEventChangeComponent, + TableCellAutoscalerEventStatusComponent + ] +}) +export class AutoscalerModule { } diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler.routing.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler.routing.ts new file mode 100644 index 0000000000..f4a0685688 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler.routing.ts @@ -0,0 +1,55 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { DynamicExtensionRoutes } from '../../../core/src/core/extension/dynamic-extension-routes'; +import { StratosActionType } from '../../../core/src/core/extension/extension-service'; +import { + PageNotFoundComponentComponent, +} from '../../../core/src/core/page-not-found-component/page-not-found-component.component'; +import { AutoscalerBaseComponent } from '../features/autoscaler-base.component'; +import { AutoscalerMetricPageComponent } from '../features/autoscaler-metric-page/autoscaler-metric-page.component'; +import { + AutoscalerScaleHistoryPageComponent, +} from '../features/autoscaler-scale-history-page/autoscaler-scale-history-page.component'; +import { EditAutoscalerPolicyComponent } from '../features/edit-autoscaler-policy/edit-autoscaler-policy.component'; + +const autoscalerRoutes: Routes = [ + { + path: '', + children: [ + { + path: ':endpointId/:id', + component: AutoscalerBaseComponent, + children: [ + { + path: 'edit-autoscaler-policy', + component: EditAutoscalerPolicyComponent, + }, + { + path: 'app-autoscaler-metric-page', + component: AutoscalerMetricPageComponent, + }, + { + path: 'app-autoscaler-scale-history-page', + component: AutoscalerScaleHistoryPageComponent, + }, + { + path: '**', + component: PageNotFoundComponentComponent, + canActivate: [DynamicExtensionRoutes], + data: { + stratosRouteGroup: StratosActionType.Application + } + } + ] + } + ] + } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(autoscalerRoutes) + ] +}) +export class AutoscalerRoutingModule { } diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.html new file mode 100644 index 0000000000..0680b43f9c --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.html @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.scss b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.scss @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts new file mode 100644 index 0000000000..9f78819d24 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts @@ -0,0 +1,48 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../core/src/core/core.module'; +import { ApplicationService } from '../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../cf-autoscaler-testing.module'; +import { AutoscalerBaseComponent } from './autoscaler-base.component'; + +describe('AutoscalerBaseComponent', () => { + let component: AutoscalerBaseComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AutoscalerBaseComponent], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AutoscalerBaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts new file mode 100644 index 0000000000..3f81cc895e --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { ApplicationService } from '../../../core/src/features/applications/application.service'; +import { getGuids } from '../../../core/src/features/applications/application/application-base.component'; +import { APP_GUID, CF_GUID } from '../../../core/src/shared/entity.tokens'; + +@Component({ + selector: 'app-autoscaler-base', + templateUrl: './autoscaler-base.component.html', + styleUrls: ['./autoscaler-base.component.scss'], + providers: [ + ApplicationService, + { + provide: CF_GUID, + useFactory: getGuids('cf'), + deps: [ActivatedRoute] + }, + { + provide: APP_GUID, + useFactory: getGuids(), + deps: [ActivatedRoute] + }, + ] +}) +export class AutoscalerBaseComponent { +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.html new file mode 100644 index 0000000000..690ac8e5c0 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.html @@ -0,0 +1,13 @@ + +

AutoScaler Metric Charts: {{ applicationName$ | async }}

+
+ +
+
+
+
+ +
+
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.scss b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.scss new file mode 100644 index 0000000000..208b05bac8 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.scss @@ -0,0 +1,5 @@ +app-list { + flex: 1; +} + + diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.spec.ts new file mode 100644 index 0000000000..0972987d63 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.spec.ts @@ -0,0 +1,48 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../cf-autoscaler-testing.module'; +import { AutoscalerMetricPageComponent } from './autoscaler-metric-page.component'; + +describe('AutoscalerMetricPageComponent', () => { + let component: AutoscalerMetricPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AutoscalerMetricPageComponent], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AutoscalerMetricPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts new file mode 100644 index 0000000000..b5954384ac --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, publishReplay, refCount } from 'rxjs/operators'; + +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { ListConfig } from '../../../../core/src/shared/components/list/list.component.types'; +import { + AppAutoscalerMetricChartListConfigService, +} from '../../shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service'; + +@Component({ + selector: 'app-autoscaler-metric-page', + templateUrl: './autoscaler-metric-page.component.html', + styleUrls: ['./autoscaler-metric-page.component.scss'], + providers: [ + { + provide: ListConfig, + useClass: AppAutoscalerMetricChartListConfigService + } + ] +}) +export class AutoscalerMetricPageComponent implements OnInit { + + parentUrl = `/applications/${this.applicationService.cfGuid}/${this.applicationService.appGuid}/autoscale`; + applicationName$: Observable; + + constructor( + public applicationService: ApplicationService, + ) { + } + + ngOnInit() { + this.applicationName$ = this.applicationService.app$.pipe( + map(({ entity }) => entity ? entity.entity.name : null), + publishReplay(1), + refCount() + ); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.html new file mode 100644 index 0000000000..453d846ab8 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.html @@ -0,0 +1,13 @@ + +

AutoScaler Scaling Events: {{ applicationName$ | async }}

+
+ +
+
+
+
+ +
+
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.scss b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.spec.ts new file mode 100644 index 0000000000..48028069df --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.spec.ts @@ -0,0 +1,48 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../cf-autoscaler-testing.module'; +import { AutoscalerScaleHistoryPageComponent } from './autoscaler-scale-history-page.component'; + +describe('AutoscalerScaleHistoryPageComponent', () => { + let component: AutoscalerScaleHistoryPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AutoscalerScaleHistoryPageComponent], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AutoscalerScaleHistoryPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts new file mode 100644 index 0000000000..211b2377d6 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts @@ -0,0 +1,38 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, publishReplay, refCount } from 'rxjs/operators'; + +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { ListConfig } from '../../../../core/src/shared/components/list/list.component.types'; +import { + CfAppAutoscalerEventsConfigService, +} from '../../shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service'; + +@Component({ + selector: 'app-autoscaler-scale-history-page', + templateUrl: './autoscaler-scale-history-page.component.html', + styleUrls: ['./autoscaler-scale-history-page.component.scss'], + providers: [{ + provide: ListConfig, + useClass: CfAppAutoscalerEventsConfigService, + }] +}) +export class AutoscalerScaleHistoryPageComponent implements OnInit { + + parentUrl = `/applications/${this.applicationService.cfGuid}/${this.applicationService.appGuid}/autoscale`; + applicationName$: Observable; + + constructor( + public applicationService: ApplicationService, + ) { + } + + ngOnInit() { + this.applicationName$ = this.applicationService.app$.pipe( + map(({ entity }) => entity ? entity.entity.name : null), + publishReplay(1), + refCount() + ); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html new file mode 100644 index 0000000000..1ea6e15f7a --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html @@ -0,0 +1,263 @@ +
+ + + + + + Status + + + + + + + + + + + + + + + + Status + + + + + + + + + + + + + + + + + Latest Metrics + + + + + + + + + + + + +
+ + + + + Scaling Rules + + + + + + + + + + + + + + Scheduled Limit Rules + + in {{policy.schedules.timezone}} + + + + + +
+ +
+ + + +
+
+
+
+ + + + + Latest Events + + + + + + + + + + + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.scss b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.scss new file mode 100644 index 0000000000..2388c6fd13 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.scss @@ -0,0 +1,71 @@ +.autoscaler-tab { + display: flex; + position: relative; + width: 100%; + &__actions { + bottom: 10px; + padding-top: 0; + position: absolute; + right: 24px; + text-align: right; + } + .mat-header-cell:first-of-type { + padding-left: unset; + } + .autoscaler-tab-table-no-record { + margin-top: .5em; + } + + &__latest-metrics { + min-height: 200px; + } +} + +.app-metadata { + display: flex; + flex-direction: row; + &__two-cols { + flex: 1; + app-metadata-item:first-child { + margin-top: 0; + } + } + app-metadata-item { + margin-bottom: 0; + } +} + +.app-metadata-table { + display: block; + padding-bottom: 8px; +} + +.app-autoscaler-tile-grid-100 { + width: 100%; +} + +table { + width: 100%; + td { + padding-left: .5em; + } +} + +.autoscaler-tile-events { + &__header { + display: flex; + flex: 1; + height: 40px; + justify-content: space-between; + padding-top: 0; + + mat-card-title { + margin-bottom: 0; + } + + button { + margin-right: -10px; + margin-top: -10px; + } + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts new file mode 100644 index 0000000000..be3c53d2be --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts @@ -0,0 +1,54 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { CoreModule } from '../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../cf-autoscaler-testing.module'; +import { AutoscalerTabExtensionComponent } from './autoscaler-tab-extension.component'; +import { CardAutoscalerDefaultComponent } from '../../shared/card-autoscaler-default/card-autoscaler-default.component'; + +describe('AutoscalerTabExtensionComponent', () => { + let component: AutoscalerTabExtensionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + AutoscalerTabExtensionComponent, + CardAutoscalerDefaultComponent, + ], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + NgxChartsModule, + RouterTestingModule, + CfAutoscalerTestingModule, + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AutoscalerTabExtensionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts new file mode 100644 index 0000000000..b011679585 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts @@ -0,0 +1,334 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, first, map, publishReplay, refCount, startWith } from 'rxjs/operators'; + +import { EntityService } from '../../../../core/src/core/entity-service'; +import { EntityServiceFactory } from '../../../../core/src/core/entity-service-factory.service'; +import { StratosTab, StratosTabType } from '../../../../core/src/core/extension/extension-service'; +import { safeUnsubscribe } from '../../../../core/src/core/utils.service'; +import { ApplicationMonitorService } from '../../../../core/src/features/applications/application-monitor.service'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { getGuids } from '../../../../core/src/features/applications/application/application-base.component'; +import { ConfirmationDialogConfig } from '../../../../core/src/shared/components/confirmation-dialog.config'; +import { ConfirmationDialogService } from '../../../../core/src/shared/components/confirmation-dialog.service'; +import { PaginationMonitorFactory } from '../../../../core/src/shared/monitors/pagination-monitor.factory'; +import { RouterNav } from '../../../../store/src/actions/router.actions'; +import { AppState } from '../../../../store/src/app-state'; +import { applicationSchemaKey, entityFactory } from '../../../../store/src/helpers/entity-factory'; +import { createEntityRelationPaginationKey } from '../../../../store/src/helpers/entity-relations/entity-relations.types'; +import { ActionState } from '../../../../store/src/reducers/api-request-reducer/types'; +import { getPaginationObservables } from '../../../../store/src/reducers/pagination-reducer/pagination-reducer.helper'; +import { selectUpdateInfo } from '../../../../store/src/selectors/api.selectors'; +import { APIResource } from '../../../../store/src/types/api.types'; +import { AutoscalerConstants } from '../../core/autoscaler-helpers/autoscaler-util'; +import { + AutoscalerPaginationParams, + DetachAppAutoscalerPolicyAction, + GetAppAutoscalerAppMetricAction, + GetAppAutoscalerPolicyAction, + GetAppAutoscalerScalingHistoryAction, + UpdateAppAutoscalerPolicyAction, +} from '../../store/app-autoscaler.actions'; +import { + AppAutoscalerFetchPolicyFailedResponse, + AppAutoscalerMetricData, + AppAutoscalerPolicy, + AppAutoscalerPolicyLocal, + AppAutoscalerScalingHistory, + AppScalingTrigger, +} from '../../store/app-autoscaler.types'; +import { + appAutoscalerAppMetricSchemaKey, + appAutoscalerPolicySchemaKey, + appAutoscalerScalingHistorySchemaKey, +} from '../../store/autoscaler.store.module'; + +const enableAutoscaler = (appGuid: string, endpointGuid: string, esf: EntityServiceFactory): Observable => { + // This will eventual be moved out into a service and made generic to the cf (one call per cf, rather than one call per app - See #3583) + const action = new GetAppAutoscalerPolicyAction(appGuid, endpointGuid); + const entityService = esf.create(action.entityKey, action.entity, action.guid, action); + return entityService.entityObs$.pipe( + filter(entityInfo => + !!entityInfo && !!entityInfo.entityRequestInfo && !!entityInfo.entityRequestInfo.response && + !entityInfo.entityRequestInfo.fetching), + map(entityInfo => { + // Autoscaler feature should be enabled if either .. + // 1) There's an autoscaler policy + // 2) There's a 404 no policy error (as opposed to a 404 url not found error) + const noPolicySet = (entityInfo.entityRequestInfo.response as AppAutoscalerFetchPolicyFailedResponse).noPolicy; + return !!entityInfo.entity || noPolicySet; + }), + startWith(true) + ); +}; + +@StratosTab({ + type: StratosTabType.Application, + label: 'Autoscale', + link: 'autoscale', + icon: 'meter', + iconFont: 'stratos-icons', + hidden: (store: Store, esf: EntityServiceFactory, activatedRoute: ActivatedRoute) => { + const endpointGuid = getGuids('cf')(activatedRoute) || window.location.pathname.split('/')[2]; + const appGuid = getGuids()(activatedRoute) || window.location.pathname.split('/')[3]; + return enableAutoscaler(appGuid, endpointGuid, esf).pipe(map(enabled => !enabled)); + } +}) +@Component({ + selector: 'app-autoscaler-tab-extension', + templateUrl: './autoscaler-tab-extension.component.html', + styleUrls: ['./autoscaler-tab-extension.component.scss'], + providers: [ + ApplicationMonitorService + ] +}) +export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { + + scalingRuleColumns: string[] = ['metric', 'condition', 'action']; + specificDateColumns: string[] = ['from', 'to', 'init', 'min', 'max']; + recurringScheduleColumns: string[] = ['effect', 'repeat', 'from', 'to', 'init', 'min', 'max']; + scalingHistoryColumns: string[] = ['event', 'trigger', 'date', 'error']; + metricTypes: string[] = AutoscalerConstants.MetricTypes; + + appAutoscalerPolicyService: EntityService>; + public appAutoscalerScalingHistoryService: EntityService>; + appAutoscalerPolicy$: Observable; + appAutoscalerPolicySafe$: Observable; + appAutoscalerScalingHistory$: Observable; + appAutoscalerAppMetricNames$: Observable; + + private appAutoscalerPolicyErrorSub: Subscription; + private appAutoscalerScalingHistoryErrorSub: Subscription; + private appAutoscalerPolicySnackBarRef: MatSnackBarRef; + private appAutoscalerScalingHistorySnackBarRef: MatSnackBarRef; + private scalingHistoryAction: GetAppAutoscalerScalingHistoryAction; + + private detachConfirmOk = 0; + + appAutoscalerAppMetrics = {}; + + paramsMetrics: AutoscalerPaginationParams = { + 'start-time': ((new Date()).getTime() - 60000).toString() + '000000', + 'end-time': (new Date()).getTime().toString() + '000000', + page: '1', + 'results-per-page': '1', + 'order-direction': 'desc' + }; + paramsHistory: AutoscalerPaginationParams = { + 'start-time': '0', + 'end-time': (new Date()).getTime().toString() + '000000', + page: '1', + 'results-per-page': '5', + 'order-direction': 'desc' + }; + + ngOnDestroy(): void { + if (this.appAutoscalerPolicySnackBarRef) { + this.appAutoscalerPolicySnackBarRef.dismiss(); + } + if (this.appAutoscalerScalingHistorySnackBarRef) { + this.appAutoscalerScalingHistorySnackBarRef.dismiss(); + } + safeUnsubscribe(this.appAutoscalerPolicyErrorSub, this.appAutoscalerScalingHistoryErrorSub); + } + + constructor( + private store: Store, + private applicationService: ApplicationService, + private entityServiceFactory: EntityServiceFactory, + private paginationMonitorFactory: PaginationMonitorFactory, + private appAutoscalerPolicySnackBar: MatSnackBar, + private appAutoscalerScalingHistorySnackBar: MatSnackBar, + private confirmDialog: ConfirmationDialogService, + ) { } + + ngOnInit() { + this.appAutoscalerPolicyService = this.entityServiceFactory.create( + appAutoscalerPolicySchemaKey, + entityFactory(appAutoscalerPolicySchemaKey), + this.applicationService.appGuid, + new GetAppAutoscalerPolicyAction(this.applicationService.appGuid, this.applicationService.cfGuid), + false + ); + this.appAutoscalerPolicy$ = this.appAutoscalerPolicyService.entityObs$.pipe( + map(({ entity }) => entity ? entity.entity : null), + publishReplay(1), + refCount() + ); + this.appAutoscalerPolicySafe$ = this.appAutoscalerPolicyService.waitForEntity$.pipe( + map(({ entity }) => entity && entity.entity), + publishReplay(1), + refCount() + ); + + this.loadLatestMetricsUponPolicy(); + + this.appAutoscalerAppMetricNames$ = this.appAutoscalerPolicySafe$.pipe( + map(entity => Object.keys(entity.scaling_rules_map)), + ); + + this.scalingHistoryAction = new GetAppAutoscalerScalingHistoryAction( + createEntityRelationPaginationKey(applicationSchemaKey, this.applicationService.appGuid, 'latest'), + this.applicationService.appGuid, + this.applicationService.cfGuid, + true, + this.paramsHistory + ); + this.appAutoscalerScalingHistoryService = this.entityServiceFactory.create( + appAutoscalerScalingHistorySchemaKey, + entityFactory(appAutoscalerScalingHistorySchemaKey), + this.applicationService.appGuid, + this.scalingHistoryAction, + false + ); + this.appAutoscalerScalingHistory$ = this.appAutoscalerScalingHistoryService.entityObs$.pipe( + map(({ entity }) => entity && entity.entity), + publishReplay(1), + refCount() + ); + this.initErrorSub(); + } + + getAppMetric(metricName: string, trigger: AppScalingTrigger, params: AutoscalerPaginationParams) { + const action = new GetAppAutoscalerAppMetricAction(this.applicationService.appGuid, + this.applicationService.cfGuid, metricName, true, trigger, params); + this.store.dispatch(action); + return getPaginationObservables({ + store: this.store, + action, + paginationMonitor: this.paginationMonitorFactory.create( + action.paginationKey, + entityFactory(appAutoscalerAppMetricSchemaKey) + ) + }, false).entities$; + } + + loadLatestMetricsUponPolicy() { + this.appAutoscalerPolicySafe$.pipe( + first(), + ).subscribe(appAutoscalerPolicy => { + this.paramsMetrics['start-time'] = ((new Date()).getTime() - 60000).toString() + '000000'; + this.paramsMetrics['end-time'] = (new Date()).getTime().toString() + '000000'; + if (appAutoscalerPolicy.scaling_rules_map) { + this.appAutoscalerAppMetrics = Object.keys(appAutoscalerPolicy.scaling_rules_map).reduce((metricMap, metricName) => { + metricMap[metricName] = this.getAppMetric(metricName, appAutoscalerPolicy.scaling_rules_map[metricName], this.paramsMetrics); + return metricMap; + }, {}); + } + }); + } + + initErrorSub() { + if (this.appAutoscalerPolicyErrorSub) { + this.appAutoscalerScalingHistoryErrorSub.unsubscribe(); + } + + this.appAutoscalerPolicyErrorSub = this.appAutoscalerPolicyService.entityMonitor.entityRequest$.pipe( + filter(request => !!request.error), + map(request => { + const msg = request.message; + request.error = false; + request.message = ''; + return msg; + }), + distinctUntilChanged(), + ).subscribe(errorMessage => { + if (this.appAutoscalerPolicySnackBarRef) { + this.appAutoscalerPolicySnackBarRef.dismiss(); + } + this.appAutoscalerPolicySnackBarRef = this.appAutoscalerPolicySnackBar.open(errorMessage, 'Dismiss'); + }); + + if (this.appAutoscalerScalingHistoryErrorSub) { + this.appAutoscalerScalingHistoryErrorSub.unsubscribe(); + } + this.appAutoscalerScalingHistoryErrorSub = this.appAutoscalerScalingHistoryService.entityMonitor.entityRequest$.pipe( + filter(request => !!request.error), + map(request => request.message), + distinctUntilChanged(), + ).subscribe(errorMessage => { + if (this.appAutoscalerScalingHistorySnackBarRef) { + this.appAutoscalerScalingHistorySnackBarRef.dismiss(); + } + this.appAutoscalerScalingHistorySnackBarRef = this.appAutoscalerScalingHistorySnackBar.open(errorMessage, 'Dismiss'); + }); + } + + disableAutoscaler() { + const confirmation = new ConfirmationDialogConfig( + 'Detach And Delete Policy', + 'Are you sure you want to detach and delete the policy?', + 'Detach and Delete', + true + ); + this.detachConfirmOk = this.detachConfirmOk === 1 ? 0 : 1; + this.confirmDialog.open(confirmation, () => { + this.detachConfirmOk = 2; + const doUpdate = () => this.detachPolicy(); + doUpdate().pipe( + first(), + ).subscribe(actionState => { + if (actionState.error) { + this.appAutoscalerPolicySnackBarRef = + this.appAutoscalerPolicySnackBar.open(`Failed to detach policy: ${actionState.message}`, 'Dismiss'); + } + }); + }); + } + + detachPolicy(): Observable { + this.store.dispatch( + new DetachAppAutoscalerPolicyAction(this.applicationService.appGuid, this.applicationService.cfGuid) + ); + const actionState = selectUpdateInfo(appAutoscalerPolicySchemaKey, + this.applicationService.appGuid, + UpdateAppAutoscalerPolicyAction.updateKey); + return this.store.select(actionState).pipe(filter(item => !!item)); + } + + updatePolicyPage = () => { + this.store.dispatch(new RouterNav({ + path: [ + 'autoscaler', + this.applicationService.cfGuid, + this.applicationService.appGuid, + 'edit-autoscaler-policy' + ] + })); + } + + metricChartPage() { + this.store.dispatch(new RouterNav({ + path: [ + 'autoscaler', + this.applicationService.cfGuid, + this.applicationService.appGuid, + 'app-autoscaler-metric-page' + ] + })); + } + + scaleHistoryPage() { + this.store.dispatch(new RouterNav({ + path: [ + 'autoscaler', + this.applicationService.cfGuid, + this.applicationService.appGuid, + 'app-autoscaler-scale-history-page' + ] + })); + } + + fetchScalingHistory() { + this.paramsHistory['end-time'] = (new Date()).getTime().toString() + '000000'; + this.store.dispatch(this.scalingHistoryAction); + } + + getMetricUnit(metricType: string) { + return AutoscalerConstants.getMetricUnit(metricType); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-base-step.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-base-step.ts new file mode 100644 index 0000000000..1a0f24e54a --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-base-step.ts @@ -0,0 +1,28 @@ +import { OnInit } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { StepOnNextFunction } from '../../../../core/src/shared/components/stepper/step/step.component'; +import { AppAutoscalerPolicy, AppAutoscalerPolicyLocal } from '../../store/app-autoscaler.types'; +import { EditAutoscalerPolicyService } from './edit-autoscaler-policy-service'; + +export abstract class EditAutoscalerPolicy implements OnInit { + public currentPolicy: AppAutoscalerPolicyLocal; + public appAutoscalerPolicy$: Observable; + + constructor(protected service: EditAutoscalerPolicyService) { } + + ngOnInit() { + this.appAutoscalerPolicy$ = this.service.getState().pipe( + map(state => { + this.currentPolicy = state; + return this.currentPolicy; + }) + ); + } + + onNext: StepOnNextFunction = () => { + this.service.setState(this.currentPolicy); + return of({ success: true }); + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-service.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-service.spec.ts new file mode 100644 index 0000000000..a2ce67df14 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-service.spec.ts @@ -0,0 +1,27 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { EntityServiceFactory } from '../../../../core/src/core/entity-service-factory.service'; +import { ApplicationsModule } from '../../../../core/src/features/applications/applications.module'; +import { createBasicStoreModule } from '../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../cf-autoscaler-testing.module'; +import { EditAutoscalerPolicyService } from './edit-autoscaler-policy-service'; + +describe('EditAutoscalerPolicyService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + EditAutoscalerPolicyService, + EntityServiceFactory, + ], + imports: [ + ApplicationsModule, + createBasicStoreModule(), + CfAutoscalerTestingModule + ] + }); + }); + + it('should be created', inject([EditAutoscalerPolicyService], (service: EditAutoscalerPolicyService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-service.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-service.ts new file mode 100644 index 0000000000..4d5752f7b2 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import * as moment from 'moment-timezone'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, first } from 'rxjs/operators'; + +import { EntityServiceFactory } from '../../../../core/src/core/entity-service-factory.service'; +import { entityFactory } from '../../../../store/src/helpers/entity-factory'; +import { EntityInfo } from '../../../../store/src/types/api.types'; +import { autoscalerTransformArrayToMap } from '../../core/autoscaler-helpers/autoscaler-transform-policy'; +import { GetAppAutoscalerPolicyAction } from '../../store/app-autoscaler.actions'; +import { AppAutoscalerPolicy, AppAutoscalerPolicyLocal } from '../../store/app-autoscaler.types'; +import { appAutoscalerPolicySchemaKey } from '../../store/autoscaler.store.module'; + +@Injectable() +export class EditAutoscalerPolicyService { + + private initialState: AppAutoscalerPolicyLocal = autoscalerTransformArrayToMap({ + instance_min_count: 1, + instance_max_count: 10, + scaling_rules: [], + schedules: { + timezone: moment.tz.guess(), + recurring_schedule: [], + specific_date: [] + } + }); + + private stateSubject = new BehaviorSubject(this.initialState); + + + constructor(private entityServiceFactory: EntityServiceFactory) { } + + updateFromStore(appGuid: string, cfGuid: string) { + const appAutoscalerPolicyService = this.entityServiceFactory.create>( + appAutoscalerPolicySchemaKey, + entityFactory(appAutoscalerPolicySchemaKey), + appGuid, + new GetAppAutoscalerPolicyAction(appGuid, cfGuid), + false + ); + + appAutoscalerPolicyService.entityObs$.pipe( + // Stop if we've failed to fetch a policy or we've finished fetching a policy + filter(({ entity, entityRequestInfo }) => + entityRequestInfo && + (entityRequestInfo.error || (!entityRequestInfo.fetching && !!entity))), + first(), + ).subscribe((({ entity }) => { + if (entity && entity.entity) { + this.stateSubject.next(entity.entity); + } + })); + } + + setState(state: AppAutoscalerPolicyLocal) { + const {...newState} = state; + this.stateSubject.next(newState); + } + + getState(): Observable { + return this.stateSubject.asObservable(); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.html b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.html new file mode 100644 index 0000000000..eab22c9e5a --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.html @@ -0,0 +1,23 @@ +
+
+ + + + {{policyAlert.alertInvalidPolicyMinimumRange}} + + + + + + {{policyAlert.alertInvalidPolicyMaximumRange}} + + + + + + {{ timezone }} + + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.scss b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.scss new file mode 100644 index 0000000000..3e0f7a6dc2 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.scss @@ -0,0 +1,4 @@ + +.autoscaler-policy-edit-limit-max { + margin-top: .5em; +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.spec.ts new file mode 100644 index 0000000000..ce9dd0097a --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.spec.ts @@ -0,0 +1,50 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../../cf-autoscaler-testing.module'; +import { EditAutoscalerPolicyStep1Component } from './edit-autoscaler-policy-step1.component'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +describe('EditAutoscalerPolicyStep1Component', () => { + let component: EditAutoscalerPolicyStep1Component; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [EditAutoscalerPolicyStep1Component], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService, + EditAutoscalerPolicyService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditAutoscalerPolicyStep1Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.ts new file mode 100644 index 0000000000..ef98dc334d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component.ts @@ -0,0 +1,96 @@ +import { Component, OnInit } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material'; +import * as moment from 'moment-timezone'; +import { of as observableOf } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { StepOnNextFunction } from '../../../../../core/src/shared/components/stepper/step/step.component'; +import { autoscalerTransformArrayToMap } from '../../../core/autoscaler-helpers/autoscaler-transform-policy'; +import { PolicyAlert } from '../../../core/autoscaler-helpers/autoscaler-util'; +import { numberWithFractionOrExceedRange } from '../../../core/autoscaler-helpers/autoscaler-validation'; +import { EditAutoscalerPolicy } from '../edit-autoscaler-policy-base-step'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +@Component({ + selector: 'app-edit-autoscaler-policy-step1', + templateUrl: './edit-autoscaler-policy-step1.component.html', + styleUrls: ['./edit-autoscaler-policy-step1.component.scss'], + providers: [ + { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher } + ] +}) +export class EditAutoscalerPolicyStep1Component extends EditAutoscalerPolicy implements OnInit { + + policyAlert = PolicyAlert; + timezoneOptions = moment.tz.names(); + editLimitForm: FormGroup; + + private editLimitValid = true; + + constructor( + public applicationService: ApplicationService, + private fb: FormBuilder, + service: EditAutoscalerPolicyService + ) { + super(service); + this.editLimitForm = this.fb.group({ + instance_min_count: [0, [Validators.required, this.validateGlobalLimitMin()]], + instance_max_count: [0, [Validators.required, this.validateGlobalLimitMax()]], + timezone: [0, [Validators.required]] + }); + } + + ngOnInit() { + this.service.updateFromStore(this.applicationService.appGuid, this.applicationService.cfGuid); + this.appAutoscalerPolicy$ = this.service.getState().pipe( + map(policy => { + this.currentPolicy = policy; + if (!this.currentPolicy.scaling_rules_form) { + this.currentPolicy = autoscalerTransformArrayToMap(this.currentPolicy); + } + this.editLimitForm.controls.timezone.setValue(this.currentPolicy.schedules.timezone); + this.editLimitForm.controls.instance_min_count.setValue(this.currentPolicy.instance_min_count); + this.editLimitForm.controls.instance_max_count.setValue(this.currentPolicy.instance_max_count); + this.editLimitForm.controls.instance_min_count.setValidators([Validators.required, this.validateGlobalLimitMin()]); + this.editLimitForm.controls.instance_max_count.setValidators([Validators.required, this.validateGlobalLimitMax()]); + return this.currentPolicy; + }) + ); + } + + onNext: StepOnNextFunction = () => { + this.currentPolicy.instance_min_count = Math.floor(this.editLimitForm.get('instance_min_count').value); + this.currentPolicy.instance_max_count = Math.floor(this.editLimitForm.get('instance_max_count').value); + this.currentPolicy.schedules.timezone = this.editLimitForm.get('timezone').value; + this.service.setState(this.currentPolicy); + return observableOf({ success: true }); + } + + validateGlobalLimitMin(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = this.editLimitForm ? + numberWithFractionOrExceedRange(control.value, 1, this.editLimitForm.get('instance_max_count').value - 1, true) : false; + const lastValid = this.editLimitValid; + this.editLimitValid = this.editLimitForm && control.value < this.editLimitForm.get('instance_max_count').value; + if (this.editLimitForm && this.editLimitValid !== lastValid) { + this.editLimitForm.controls.instance_max_count.updateValueAndValidity(); + } + return invalid ? { alertInvalidPolicyMinimumRange: { value: control.value } } : null; + }; + } + + validateGlobalLimitMax(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = this.editLimitForm ? numberWithFractionOrExceedRange(control.value, + this.editLimitForm.get('instance_min_count').value + 1, Number.MAX_VALUE, true) : false; + const lastValid = this.editLimitValid; + this.editLimitValid = this.editLimitForm && this.editLimitForm.get('instance_min_count').value < control.value; + if (this.editLimitForm && this.editLimitValid !== lastValid) { + this.editLimitForm.controls.instance_min_count.updateValueAndValidity(); + } + return invalid ? { alertInvalidPolicyMaximumRange: { value: control.value } } : null; + }; + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.html b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.html new file mode 100644 index 0000000000..5f15c5934d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.html @@ -0,0 +1,110 @@ +
+ + + + + + + Rule {{index+1}}: + + + +
+ If average {{rule.metric_type}} {{rule.operator}} {{rule.threshold}} {{getMetricUnit(rule.metric_type)}} + for {{rule.breach_duration_secs}} seconds, then {{rule.adjustment}} instances. Cooldown: + {{rule.cool_down_secs}} seconds. +
+
+
+ If average + + + + {{ metricType }} + + + + + + + {{ operator }} + + + + + + + {{getMetricUnit(rule.metric_type)}} for + + + + seconsds, then {{editScaleType=='upper'?'add':'remove'}} + + + + + + instances + % instances + + + . Cooldown: + + + + seconds. +
+ + Threshold is required + + + {{policyAlert.alertInvalidPolicyTriggerThreshold100}} + + + + {{policyAlert.alertInvalidPolicyTriggerUpperThresholdRange}} + + + {{policyAlert.alertInvalidPolicyTriggerLowerThresholdRange}} + + + + {{policyAlert.alertInvalidPolicyTriggerBreachDurationRange}} + + + Adjustment is required + + + + {{policyAlert.alertInvalidPolicyTriggerStepRange}} + + + {{policyAlert.alertInvalidPolicyTriggerStepPercentageRange}} + + + + {{policyAlert.alertInvalidPolicyTriggerCooldownRange}} + +
+
+ + + + + +
+
+
+
+ +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.scss b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.scss new file mode 100644 index 0000000000..2e060b8936 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.scss @@ -0,0 +1,25 @@ +.app-autoscaler-tile-grid-100 { + width: 100%; +} + +.autoscaler-policy-edit { + display: flex; + position: relative; + width: 100%; + &__actions { + bottom: 10px; + padding-top: 0; + position: absolute; + right: 24px; + text-align: right; + } +} + +.autoscaler-policy-edit-trigger-item { + .autoscaler-policy-edit-trigger-item-desc { + margin-bottom: .5em; + } + mat-error { + font-size: 85%; + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.spec.ts new file mode 100644 index 0000000000..8cbb1fa5a7 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.spec.ts @@ -0,0 +1,50 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../../cf-autoscaler-testing.module'; +import { EditAutoscalerPolicyStep2Component } from './edit-autoscaler-policy-step2.component'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +describe('EditAutoscalerPolicyStep2Component', () => { + let component: EditAutoscalerPolicyStep2Component; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [EditAutoscalerPolicyStep2Component], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService, + EditAutoscalerPolicyService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditAutoscalerPolicyStep2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.ts new file mode 100644 index 0000000000..29165b0c00 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component.ts @@ -0,0 +1,188 @@ +import { Component, OnInit } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material'; +import { Observable } from 'rxjs'; + +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { + AutoscalerConstants, + getAdjustmentType, + getScaleType, + PolicyAlert, +} from '../../../core/autoscaler-helpers/autoscaler-util'; +import { + getThresholdMax, + getThresholdMin, + numberWithFractionOrExceedRange, +} from '../../../core/autoscaler-helpers/autoscaler-validation'; +import { AppAutoscalerPolicy, AppAutoscalerPolicyLocal, AppAutoscalerInvalidPolicyError } from '../../../store/app-autoscaler.types'; +import { EditAutoscalerPolicy } from '../edit-autoscaler-policy-base-step'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +@Component({ + selector: 'app-edit-autoscaler-policy-step2', + templateUrl: './edit-autoscaler-policy-step2.component.html', + styleUrls: ['./edit-autoscaler-policy-step2.component.scss'], + providers: [ + { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher } + ] +}) +export class EditAutoscalerPolicyStep2Component extends EditAutoscalerPolicy implements OnInit { + + policyAlert = PolicyAlert; + metricTypes = AutoscalerConstants.MetricTypes; + operatorTypes = AutoscalerConstants.UpperOperators.concat(AutoscalerConstants.LowerOperators); + editTriggerForm: FormGroup; + appAutoscalerPolicy$: Observable; + + public currentPolicy: AppAutoscalerPolicyLocal; + public testing = false; + private editIndex = -1; + private editMetricType = ''; + private editScaleType = 'upper'; + private editAdjustmentType = 'value'; + + constructor( + public applicationService: ApplicationService, + private fb: FormBuilder, + service: EditAutoscalerPolicyService + ) { + super(service); + this.editTriggerForm = this.fb.group({ + metric_type: [0, this.validateTriggerMetricType()], + operator: [0, this.validateTriggerOperator()], + threshold: [0, [Validators.required, Validators.min(1), this.validateTriggerThreshold()]], + adjustment: [0, [Validators.required, Validators.min(1), this.validateTriggerAdjustment()]], + breach_duration_secs: [0, [ + Validators.min(AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_min), + Validators.max(AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_max) + ]], + cool_down_secs: [0, [ + Validators.min(AutoscalerConstants.PolicyDefaultSetting.cool_down_secs_min), + Validators.max(AutoscalerConstants.PolicyDefaultSetting.cool_down_secs_max) + ]], + adjustment_type: [0, this.validateTriggerAdjustmentType()] + }); + } + + addTrigger = () => { + const { ...newTrigger } = AutoscalerConstants.PolicyDefaultTrigger; + this.currentPolicy.scaling_rules_form.push(newTrigger); + this.editTrigger(this.currentPolicy.scaling_rules_form.length - 1); + } + + removeTrigger(index: number) { + if (this.editIndex === index) { + this.editIndex = -1; + } + this.currentPolicy.scaling_rules_form.splice(index, 1); + } + + editTrigger(index: number) { + this.editIndex = index; + this.editMetricType = this.currentPolicy.scaling_rules_form[index].metric_type; + this.editScaleType = getScaleType(this.currentPolicy.scaling_rules_form[index].operator); + this.editAdjustmentType = getAdjustmentType(this.currentPolicy.scaling_rules_form[index].adjustment); + this.editTriggerForm.setValue({ + metric_type: this.editMetricType, + operator: this.currentPolicy.scaling_rules_form[index].operator, + threshold: this.currentPolicy.scaling_rules_form[index].threshold, + adjustment: Math.abs(Number(this.currentPolicy.scaling_rules_form[index].adjustment)), + breach_duration_secs: this.currentPolicy.scaling_rules_form[index].breach_duration_secs, + cool_down_secs: this.currentPolicy.scaling_rules_form[index].cool_down_secs, + adjustment_type: this.editAdjustmentType + }); + } + + finishTrigger() { + const adjustmentP = this.editTriggerForm.get('adjustment_type').value === 'value' ? '' : '%'; + const adjustmentI = this.editTriggerForm.get('adjustment').value; + const adjustmentM = this.editScaleType === 'upper' ? `+${adjustmentI}${adjustmentP}` : `-${adjustmentI}${adjustmentP}`; + this.currentPolicy.scaling_rules_form[this.editIndex].metric_type = this.editTriggerForm.get('metric_type').value; + this.currentPolicy.scaling_rules_form[this.editIndex].operator = this.editTriggerForm.get('operator').value; + this.currentPolicy.scaling_rules_form[this.editIndex].threshold = this.editTriggerForm.get('threshold').value; + this.currentPolicy.scaling_rules_form[this.editIndex].adjustment = adjustmentM; + if (this.editTriggerForm.get('breach_duration_secs').value) { + this.currentPolicy.scaling_rules_form[this.editIndex].breach_duration_secs = + this.editTriggerForm.get('breach_duration_secs').value; + } else { + this.currentPolicy.scaling_rules_form[this.editIndex].breach_duration_secs = + AutoscalerConstants.PolicyDefaultSetting.breach_duration_secs_default; + } + if (this.editTriggerForm.get('cool_down_secs').value) { + this.currentPolicy.scaling_rules_form[this.editIndex].cool_down_secs = this.editTriggerForm.get('cool_down_secs').value; + } else { + this.currentPolicy.scaling_rules_form[this.editIndex].cool_down_secs = + AutoscalerConstants.PolicyDefaultSetting.cool_down_secs_default; + } + this.editIndex = -1; + } + + validateTriggerMetricType(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (this.editTriggerForm) { + this.editMetricType = control.value; + this.editTriggerForm.controls.threshold.updateValueAndValidity(); + } + return null; + }; + } + + validateTriggerOperator(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (this.editTriggerForm) { + this.editScaleType = getScaleType(control.value); + this.editTriggerForm.controls.threshold.updateValueAndValidity(); + } + return null; + }; + } + + validateTriggerThreshold(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (!this.editTriggerForm) { + return null; + } + const errors: AppAutoscalerInvalidPolicyError = {}; + if (AutoscalerConstants.MetricPercentageTypes.indexOf(this.editMetricType) >= 0) { + if (numberWithFractionOrExceedRange(control.value, 1, 100, true)) { + errors.alertInvalidPolicyTriggerThreshold100 = { value: control.value }; + } + } + const thresholdMin = getThresholdMin(this.currentPolicy.scaling_rules_form, this.editMetricType, this.editScaleType, this.editIndex); + const thresholdMax = getThresholdMax(this.currentPolicy.scaling_rules_form, this.editMetricType, this.editScaleType, this.editIndex); + if (numberWithFractionOrExceedRange(control.value, thresholdMin, thresholdMax, true)) { + errors.alertInvalidPolicyTriggerThresholdRange = { value: control.value }; + } + return Object.keys(errors).length === 0 ? null : errors; + }; + } + + validateTriggerAdjustment(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (!this.editTriggerForm) { + return null; + } + const errors: AppAutoscalerInvalidPolicyError = {}; + const max = this.editAdjustmentType === 'value' ? this.currentPolicy.instance_max_count - 1 : Number.MAX_VALUE; + if (numberWithFractionOrExceedRange(control.value, 1, max, true)) { + errors.alertInvalidPolicyTriggerStepRange = {}; + } + return Object.keys(errors).length === 0 ? null : errors; + }; + } + + validateTriggerAdjustmentType(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (this.editTriggerForm) { + this.editAdjustmentType = control.value; + this.editTriggerForm.controls.adjustment.updateValueAndValidity(); + } + return null; + }; + } + + getMetricUnit(metricType: string) { + return AutoscalerConstants.getMetricUnit(metricType); + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.html b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.html new file mode 100644 index 0000000000..4ed111d0e6 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.html @@ -0,0 +1,157 @@ +
+ + + + + + + Schedule {{index+1}}: + + + +
+ The initial number of application instances is + {{rule.initial_min_instance_count}}, and the range + The number of application instances + is limited from {{rule.instance_min_count}} to {{rule.instance_max_count}} during + {{rule.start_time}} ~ {{rule.end_time}} every + {{rule.days_of_month}} of the month + {{rule.days_of_week}} of the week + , effective from {{rule.start_date}} to {{rule.end_date}}. + . +
+
+
+ +
+ + {{policyAlert.alertInvalidPolicyMinimumRange}} + + + {{policyAlert.alertInvalidPolicyMaximumRange}} + + + {{policyAlert.alertInvalidPolicyInitialMaximumRange}} + + + {{policyAlert.alertInvalidPolicyScheduleDateBeforeNow}} + + + {{policyAlert.alertInvalidPolicyScheduleEndDateBeforeStartDate}} + + + {{policyAlert.alertInvalidPolicyScheduleEndTimeBeforeStartTime}} + + + {{policyAlert.alertInvalidPolicyScheduleRepeatOn}} + + + days_of_week {{policyAlert.alertInvalidPolicyScheduleRecurringConflict}} + + + {{policyAlert.alertInvalidPolicyScheduleRepeatOn}} + + + days_of_month {{policyAlert.alertInvalidPolicyScheduleRecurringConflict}} + +
+
+ + + + + +
+
+
+
+ +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.scss b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.scss new file mode 100644 index 0000000000..59c4ea6d60 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.scss @@ -0,0 +1,47 @@ +.mat-radio-outer-circle { + height: 15px; + top: 2.5px; + width: 15px; +} + +.app-autoscaler-tile-grid-100 { + width: 100%; +} + +.autoscaler-policy-edit { + display: flex; + position: relative; + width: 100%; + &__actions { + bottom: 10px; + padding-top: 0; + position: absolute; + right: 24px; + text-align: right; + } +} + +.app-metadata { + display: flex; + flex-direction: row; + &__two-cols { + flex: 1; + margin-right: 3em; + app-metadata-item:first-child { + margin-top: 0; + } + } +} + +.autoscaler-policy-edit-recurring { + max-width: 100%; + .form-field-left { + margin-right: .5em; + } + .form-field-30 { + width: 30%; + } + .form-field-50 { + width: 50%; + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.spec.ts new file mode 100644 index 0000000000..186a4dac47 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.spec.ts @@ -0,0 +1,50 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../../cf-autoscaler-testing.module'; +import { EditAutoscalerPolicyStep3Component } from './edit-autoscaler-policy-step3.component'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +describe('EditAutoscalerPolicyStep3Component', () => { + let component: EditAutoscalerPolicyStep3Component; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [EditAutoscalerPolicyStep3Component], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService, + EditAutoscalerPolicyService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditAutoscalerPolicyStep3Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.ts new file mode 100644 index 0000000000..34eebe9b2b --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component.ts @@ -0,0 +1,247 @@ +import { Component, OnInit } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material'; +import * as moment from 'moment-timezone'; +import { Observable } from 'rxjs'; + +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { AutoscalerConstants, PolicyAlert, shiftArray } from '../../../core/autoscaler-helpers/autoscaler-util'; +import { + dateIsAfter, + numberWithFractionOrExceedRange, + recurringSchedulesOverlapping, + timeIsSameOrAfter, +} from '../../../core/autoscaler-helpers/autoscaler-validation'; +import { AppAutoscalerPolicy, AppAutoscalerPolicyLocal, AppAutoscalerInvalidPolicyError } from '../../../store/app-autoscaler.types'; +import { EditAutoscalerPolicy } from '../edit-autoscaler-policy-base-step'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; +import { + validateRecurringSpecificMax, + validateRecurringSpecificMin, +} from '../edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component'; + +@Component({ + selector: 'app-edit-autoscaler-policy-step3', + templateUrl: './edit-autoscaler-policy-step3.component.html', + styleUrls: ['./edit-autoscaler-policy-step3.component.scss'], + providers: [ + { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher } + ] +}) +export class EditAutoscalerPolicyStep3Component extends EditAutoscalerPolicy implements OnInit { + + policyAlert = PolicyAlert; + weekdayOptions = AutoscalerConstants.WeekdayOptions; + monthdayOptions = AutoscalerConstants.MonthdayOptions; + editRecurringScheduleForm: FormGroup; + appAutoscalerPolicy$: Observable; + + public currentPolicy: AppAutoscalerPolicyLocal; + private editIndex = -1; + private editEffectiveType = 'always'; + private editRepeatType = 'week'; + private editMutualValidation = { + limit: true, + date: true, + time: true + }; + + constructor( + public applicationService: ApplicationService, + private fb: FormBuilder, + service: EditAutoscalerPolicyService + ) { + super(service); + this.editRecurringScheduleForm = this.fb.group({ + days_of_week: [0], + days_of_month: [0], + instance_min_count: [0], + instance_max_count: [0], + initial_min_instance_count: [0, [this.validateRecurringScheduleInitialMin()]], + start_date: [0, [this.validateRecurringScheduleGlobal()]], + end_date: [0, [this.validateRecurringScheduleGlobal()]], + start_time: [0, [Validators.required, this.validateRecurringScheduleTime('end_time'), this.validateRecurringScheduleGlobal()]], + end_time: [0, [Validators.required, this.validateRecurringScheduleTime('start_time'), this.validateRecurringScheduleGlobal()]], + effective_type: [0, [Validators.required, this.validateRecurringScheduleGlobal()]], + repeat_type: [0, [Validators.required, this.validateRecurringScheduleGlobal()]], + }); + } + + addRecurringSchedule = () => { + const {...newSchedule} = AutoscalerConstants.PolicyDefaultRecurringSchedule; + this.currentPolicy.schedules.recurring_schedule.push(newSchedule); + this.editRecurringSchedule(this.currentPolicy.schedules.recurring_schedule.length - 1); + } + + removeRecurringSchedule(index: number) { + if (this.editIndex === index) { + this.editIndex = -1; + } + this.currentPolicy.schedules.recurring_schedule.splice(index, 1); + } + + editRecurringSchedule(index: number) { + const editSchedule = this.currentPolicy.schedules.recurring_schedule[index]; + this.editIndex = index; + this.editEffectiveType = editSchedule.start_date ? 'custom' : 'always'; + this.editRepeatType = editSchedule.days_of_week ? 'week' : 'month'; + this.editRecurringScheduleForm.setValue({ + days_of_week: shiftArray(editSchedule.days_of_week || [], -1), + days_of_month: shiftArray(editSchedule.days_of_month || [], -1), + instance_min_count: editSchedule.instance_min_count, + instance_max_count: Math.abs(Number(editSchedule.instance_max_count)), + initial_min_instance_count: editSchedule.initial_min_instance_count, + start_date: editSchedule.start_date || '', + end_date: editSchedule.end_date || '', + start_time: editSchedule.start_time, + end_time: editSchedule.end_time, + effective_type: this.editEffectiveType, + repeat_type: this.editRepeatType, + }); + this.setRecurringScheduleValidator(); + } + + setRecurringScheduleValidator() { + this.editRecurringScheduleForm.controls.instance_min_count.setValidators([Validators.required, + validateRecurringSpecificMin(this.editRecurringScheduleForm, this.editMutualValidation)]); + this.editRecurringScheduleForm.controls.instance_max_count.setValidators([Validators.required, + validateRecurringSpecificMax(this.editRecurringScheduleForm, this.editMutualValidation)]); + if (this.editEffectiveType === 'custom') { + if (!this.currentPolicy.schedules.recurring_schedule[this.editIndex].start_date && + !this.editRecurringScheduleForm.get('start_date').value) { + this.editRecurringScheduleForm.controls.start_date.setValue(moment().add(1, 'days').format(AutoscalerConstants.MomentFormateDate)); + this.editRecurringScheduleForm.controls.end_date.setValue(moment().add(1, 'days').format(AutoscalerConstants.MomentFormateDate)); + } + this.editRecurringScheduleForm.controls.start_date.setValidators([Validators.required, + this.validateRecurringScheduleDate('end_date'), this.validateRecurringScheduleGlobal()]); + this.editRecurringScheduleForm.controls.end_date.setValidators([Validators.required, + this.validateRecurringScheduleDate('start_date'), this.validateRecurringScheduleGlobal()]); + } else { + this.clearValidatorsThenRevalidate(this.editRecurringScheduleForm.controls.start_date); + this.clearValidatorsThenRevalidate(this.editRecurringScheduleForm.controls.end_date); + } + if (this.editRepeatType === 'week') { + this.editRecurringScheduleForm.controls.days_of_week.setValidators([Validators.required, this.validateRecurringScheduleWeekMonth()]); + this.clearValidatorsThenRevalidate(this.editRecurringScheduleForm.controls.days_of_month); + } else { + this.editRecurringScheduleForm.controls.days_of_month.setValidators([Validators.required, this.validateRecurringScheduleWeekMonth()]); + this.clearValidatorsThenRevalidate(this.editRecurringScheduleForm.controls.days_of_week); + } + } + + clearValidatorsThenRevalidate(input) { + input.clearValidators(); + input.updateValueAndValidity(); + } + + finishRecurringSchedule() { + const currentSchedule = this.currentPolicy.schedules.recurring_schedule[this.editIndex]; + const repeatOn = 'days_of_' + this.editRepeatType; + if (this.editRecurringScheduleForm.get('effective_type').value === 'custom') { + currentSchedule.start_date = this.editRecurringScheduleForm.get('start_date').value; + currentSchedule.end_date = this.editRecurringScheduleForm.get('end_date').value; + } else { + delete currentSchedule.start_date; + delete currentSchedule.end_date; + } + delete currentSchedule.days_of_month; + delete currentSchedule.days_of_week; + currentSchedule[repeatOn] = shiftArray(this.editRecurringScheduleForm.get(repeatOn).value, 1); + if (this.editRecurringScheduleForm.get('initial_min_instance_count').value) { + currentSchedule.initial_min_instance_count = this.editRecurringScheduleForm.get('initial_min_instance_count').value; + } else { + delete currentSchedule.initial_min_instance_count; + } + currentSchedule.instance_min_count = this.editRecurringScheduleForm.get('instance_min_count').value; + currentSchedule.instance_max_count = this.editRecurringScheduleForm.get('instance_max_count').value; + currentSchedule.start_time = this.editRecurringScheduleForm.get('start_time').value; + currentSchedule.end_time = this.editRecurringScheduleForm.get('end_time').value; + this.editIndex = -1; + } + + validateRecurringScheduleGlobal(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (this.editRecurringScheduleForm) { + if (this.editRepeatType === 'week') { + this.editRecurringScheduleForm.controls.days_of_week.updateValueAndValidity(); + } else { + this.editRecurringScheduleForm.controls.days_of_month.updateValueAndValidity(); + } + } + return null; + }; + } + + validateRecurringScheduleInitialMin(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = this.editRecurringScheduleForm && + numberWithFractionOrExceedRange(control.value, this.editRecurringScheduleForm.get('instance_min_count').value, + this.editRecurringScheduleForm.get('instance_max_count').value + 1, false); + return invalid ? { alertInvalidPolicyInitialMaximumRange: { value: control.value } } : null; + }; + } + + validateRecurringScheduleDate(mutualName: string): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (this.editEffectiveType === 'always') { + return null; + } + const errors: AppAutoscalerInvalidPolicyError = {}; + if (dateIsAfter(moment().format(AutoscalerConstants.MomentFormateDate), control.value)) { + errors.alertInvalidPolicyScheduleDateBeforeNow = { value: control.value }; + } + const lastValid = this.editMutualValidation.date; + this.editMutualValidation.date = + !dateIsAfter(this.editRecurringScheduleForm.get('start_date').value, this.editRecurringScheduleForm.get('end_date').value); + if (!this.editMutualValidation.date) { + errors.alertInvalidPolicyScheduleEndDateBeforeStartDate = { value: control.value }; + } + this.mutualValidate(mutualName, lastValid, this.editMutualValidation.date); + return Object.keys(errors).length === 0 ? null : errors; + }; + } + + validateRecurringScheduleTime(mutualName: string): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = this.editRecurringScheduleForm && + timeIsSameOrAfter(this.editRecurringScheduleForm.get('start_time').value, this.editRecurringScheduleForm.get('end_time').value); + const lastValid = this.editMutualValidation.time; + this.editMutualValidation.time = !invalid; + this.mutualValidate(mutualName, lastValid, this.editMutualValidation.time); + return invalid ? { alertInvalidPolicyScheduleEndTimeBeforeStartTime: { value: control.value } } : null; + }; + } + + validateRecurringScheduleWeekMonth(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const newSchedule: any = { + start_time: this.editRecurringScheduleForm.get('start_time').value, + end_time: this.editRecurringScheduleForm.get('end_time').value + }; + newSchedule['days_of_' + this.editRepeatType] = shiftArray(control.value, 1); + if (this.editEffectiveType === 'custom') { + newSchedule.start_date = this.editRecurringScheduleForm.get('start_date').value; + newSchedule.end_date = this.editRecurringScheduleForm.get('end_date').value; + } + const invalid = recurringSchedulesOverlapping(newSchedule, this.editIndex, + this.currentPolicy.schedules.recurring_schedule, 'days_of_' + this.editRepeatType); + return invalid ? { alertInvalidPolicyScheduleRecurringConflict: { value: control.value } } : null; + }; + } + + resetEffectiveType(key: string) { + this.editEffectiveType = key; + this.setRecurringScheduleValidator(); + } + + resetRepeatType(key: string) { + this.editRepeatType = key; + this.setRecurringScheduleValidator(); + } + + mutualValidate(inputName: string, lastValid: boolean, currentValid: boolean) { + if (this.editRecurringScheduleForm && lastValid !== currentValid) { + this.editRecurringScheduleForm.controls[inputName].updateValueAndValidity(); + } + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.html b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.html new file mode 100644 index 0000000000..948cf3574a --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.html @@ -0,0 +1,101 @@ +
+ + + + + + + Schedule {{index+1}}: + + + +
+ The initial number of application instances is + {{rule.initial_min_instance_count}}, and the range + The number of application instances + is limited from {{rule.instance_min_count}} to {{rule.instance_max_count}} from + {{rule.start_date_time}} to {{rule.end_date_time}}. +
+
+
+ +
+ + {{policyAlert.alertInvalidPolicyMinimumRange}} + + + {{policyAlert.alertInvalidPolicyMaximumRange}} + + + {{policyAlert.alertInvalidPolicyInitialMaximumRange}} + + + {{policyAlert.alertInvalidPolicyScheduleStartDateTimeBeforeNow}} + + + {{policyAlert.alertInvalidPolicyScheduleEndDateTimeBeforeStartDateTime}} + + + {{policyAlert.alertInvalidPolicyScheduleSpecificConflict}} + + + {{policyAlert.alertInvalidPolicyScheduleEndDateTimeBeforeNow}} + +
+
+ + + + + +
+
+
+
+ +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.scss b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.scss new file mode 100644 index 0000000000..7cb3f43091 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.scss @@ -0,0 +1,39 @@ +.app-autoscaler-tile-grid-100 { + width: 100%; +} + +.autoscaler-policy-edit { + display: flex; + position: relative; + width: 100%; + &__actions { + bottom: 10px; + padding-top: 0; + position: absolute; + right: 24px; + text-align: right; + } +} + +.app-metadata { + display: flex; + flex-direction: row; + &__two-cols { + flex: 1; + margin-right: 3em; + app-metadata-item:first-child { + margin-top: 0; + } + } +} + +.autoscaler-policy-edit-specific { + max-width: 100%; + .form-field-left { + margin-right: .5em; + } + .form-field-30 { + width: 30%; + } +} + diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.spec.ts new file mode 100644 index 0000000000..b965864158 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.spec.ts @@ -0,0 +1,50 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../../cf-autoscaler-testing.module'; +import { EditAutoscalerPolicyStep4Component } from './edit-autoscaler-policy-step4.component'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +describe('EditAutoscalerPolicyStep4Component', () => { + let component: EditAutoscalerPolicyStep4Component; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [EditAutoscalerPolicyStep4Component], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService, + EditAutoscalerPolicyService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditAutoscalerPolicyStep4Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.ts new file mode 100644 index 0000000000..f43e297500 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component.ts @@ -0,0 +1,284 @@ +import { Component, OnInit } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material'; +import { Store } from '@ngrx/store'; +import * as moment from 'moment-timezone'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; + +import { EntityService } from '../../../../../core/src/core/entity-service'; +import { EntityServiceFactory } from '../../../../../core/src/core/entity-service-factory.service'; +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { StepOnNextFunction } from '../../../../../core/src/shared/components/stepper/step/step.component'; +import { AppState } from '../../../../../store/src/app-state'; +import { entityFactory } from '../../../../../store/src/helpers/entity-factory'; +import { AutoscalerConstants, PolicyAlert } from '../../../core/autoscaler-helpers/autoscaler-util'; +import { + dateTimeIsSameOrAfter, + numberWithFractionOrExceedRange, + specificDateRangeOverlapping, +} from '../../../core/autoscaler-helpers/autoscaler-validation'; +import { UpdateAppAutoscalerPolicyAction } from '../../../store/app-autoscaler.actions'; +import { + AppAutoscalerPolicy, + AppAutoscalerPolicyLocal, + AppSpecificDate, + AppAutoscalerInvalidPolicyError } from '../../../store/app-autoscaler.types'; +import { appAutoscalerPolicySchemaKey } from '../../../store/autoscaler.store.module'; +import { EditAutoscalerPolicy } from '../edit-autoscaler-policy-base-step'; +import { EditAutoscalerPolicyService } from '../edit-autoscaler-policy-service'; + +@Component({ + selector: 'app-edit-autoscaler-policy-step4', + templateUrl: './edit-autoscaler-policy-step4.component.html', + styleUrls: ['./edit-autoscaler-policy-step4.component.scss'], + providers: [ + { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher } + ] +}) +export class EditAutoscalerPolicyStep4Component extends EditAutoscalerPolicy implements OnInit { + + policyAlert = PolicyAlert; + editSpecificDateForm: FormGroup; + appAutoscalerPolicy$: Observable; + + private updateAppAutoscalerPolicyService: EntityService; + public currentPolicy: AppAutoscalerPolicyLocal; + private editIndex = -1; + private editMutualValidation = { + limit: true, + datetime: true + }; + + constructor( + public applicationService: ApplicationService, + private store: Store, + private fb: FormBuilder, + private entityServiceFactory: EntityServiceFactory, + service: EditAutoscalerPolicyService + ) { + super(service); + this.editSpecificDateForm = this.fb.group({ + instance_min_count: [0], + instance_max_count: [0], + initial_min_instance_count: [0, [this.validateSpecificDateInitialMin()]], + start_date_time: [0, [Validators.required, this.validateSpecificDateStartDateTime()]], + end_date_time: [0, [Validators.required, this.validateSpecificDateEndDateTime()]] + }); + } + + ngOnInit() { + super.ngOnInit(); + this.updateAppAutoscalerPolicyService = this.entityServiceFactory.create( + appAutoscalerPolicySchemaKey, + entityFactory(appAutoscalerPolicySchemaKey), + this.applicationService.appGuid, + new UpdateAppAutoscalerPolicyAction(this.applicationService.appGuid, this.applicationService.cfGuid, this.currentPolicy), + false + ); + } + + updatePolicy: StepOnNextFunction = () => { + if (this.validateGlobalSetting()) { + return observableOf({ + success: false, + message: `Could not update policy: ${PolicyAlert.alertInvalidPolicyTriggerScheduleEmpty}`, + }); + } + this.store.dispatch( + new UpdateAppAutoscalerPolicyAction(this.applicationService.appGuid, this.applicationService.cfGuid, this.currentPolicy) + ); + const waitForAppAutoscalerUpdateStatus$ = this.updateAppAutoscalerPolicyService.entityMonitor.entityRequest$.pipe( + filter(request => { + if (request.message && request.message.indexOf('fetch policy') >= 0) { + request.message = ''; + return false; + } else { + return !!request.error || !!request.response; + } + }), + map(request => { + const msg = request.message; + request.error = false; + request.response = null; + request.message = ''; + return msg; + }), + distinctUntilChanged(), + ).pipe(map( + errorMessage => { + if (errorMessage) { + return { + success: false, + message: `Could not update policy: ${errorMessage}`, + }; + } else { + return { + success: true, + redirect: true + }; + } + })); + return waitForAppAutoscalerUpdateStatus$.pipe(take(1), map(res => { + return { + ...res, + }; + })); + } + + addSpecificDate = () => { + const {...newSchedule} = AutoscalerConstants.PolicyDefaultSpecificDate; + this.currentPolicy.schedules.specific_date.push(newSchedule); + this.editSpecificDate(this.currentPolicy.schedules.specific_date.length - 1); + } + + removeSpecificDate(index: number) { + if (this.editIndex === index) { + this.editIndex = -1; + } + this.currentPolicy.schedules.specific_date.splice(index, 1); + } + + editSpecificDate(index: number) { + this.editIndex = index; + this.editSpecificDateForm.setValue({ + instance_min_count: this.currentPolicy.schedules.specific_date[index].instance_min_count, + instance_max_count: Math.abs(Number(this.currentPolicy.schedules.specific_date[index].instance_max_count)), + initial_min_instance_count: this.currentPolicy.schedules.specific_date[index].initial_min_instance_count, + start_date_time: this.currentPolicy.schedules.specific_date[index].start_date_time, + end_date_time: this.currentPolicy.schedules.specific_date[index].end_date_time, + }); + this.editSpecificDateForm.controls.instance_min_count.setValidators([Validators.required, + validateRecurringSpecificMin(this.editSpecificDateForm, this.editMutualValidation)]); + this.editSpecificDateForm.controls.instance_max_count.setValidators([Validators.required, + validateRecurringSpecificMax(this.editSpecificDateForm, this.editMutualValidation)]); + } + + finishSpecificDate() { + if (this.editSpecificDateForm.get('initial_min_instance_count').value) { + this.currentPolicy.schedules.specific_date[this.editIndex].initial_min_instance_count = + this.editSpecificDateForm.get('initial_min_instance_count').value; + } else { + delete this.currentPolicy.schedules.specific_date[this.editIndex].initial_min_instance_count; + } + this.currentPolicy.schedules.specific_date[this.editIndex].instance_min_count = + this.editSpecificDateForm.get('instance_min_count').value; + this.currentPolicy.schedules.specific_date[this.editIndex].instance_max_count = + this.editSpecificDateForm.get('instance_max_count').value; + this.currentPolicy.schedules.specific_date[this.editIndex].start_date_time = this.editSpecificDateForm.get('start_date_time').value; + this.currentPolicy.schedules.specific_date[this.editIndex].end_date_time = this.editSpecificDateForm.get('end_date_time').value; + this.editIndex = -1; + } + + validateSpecificDateInitialMin(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = this.editSpecificDateForm && numberWithFractionOrExceedRange(control.value, + this.editSpecificDateForm.get('instance_min_count').value, this.editSpecificDateForm.get('instance_max_count').value + 1, false); + return invalid ? { alertInvalidPolicyInitialMaximumRange: { value: control.value } } : null; + }; + } + + validateSpecificDateStartDateTime(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (!this.editSpecificDateForm) { + return null; + } + const errors: AppAutoscalerInvalidPolicyError = {}; + const newSchedule: AppSpecificDate = { + instance_min_count: 0, + instance_max_count: 0, + start_date_time: control.value, + end_date_time: this.editSpecificDateForm.get('end_date_time').value + }; + const lastValid = this.editMutualValidation.datetime; + this.editMutualValidation.datetime = true; + if (dateTimeIsSameOrAfter(moment().tz(this.currentPolicy.schedules.timezone) + .format(AutoscalerConstants.MomentFormateDateTimeT), control.value)) { + errors.alertInvalidPolicyScheduleStartDateTimeBeforeNow = { value: control.value }; + } + if (dateTimeIsSameOrAfter(control.value, this.editSpecificDateForm.get('end_date_time').value)) { + this.editMutualValidation.datetime = false; + errors.alertInvalidPolicyScheduleEndDateTimeBeforeStartDateTime = { value: control.value }; + } + if (specificDateRangeOverlapping(newSchedule, this.editIndex, this.currentPolicy.schedules.specific_date)) { + this.editMutualValidation.datetime = false; + errors.alertInvalidPolicyScheduleSpecificConflict = { value: control.value }; + } + if (this.editSpecificDateForm && lastValid !== this.editMutualValidation.datetime) { + this.editSpecificDateForm.controls.end_date_time.updateValueAndValidity(); + } + return Object.keys(errors).length === 0 ? null : errors; + }; + } + + validateSpecificDateEndDateTime(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + if (!this.editSpecificDateForm) { + return null; + } + const errors: AppAutoscalerInvalidPolicyError = {}; + const newSchedule = { + instance_min_count: 0, + instance_max_count: 0, + start_date_time: this.editSpecificDateForm.get('start_date_time').value, + end_date_time: control.value + }; + const lastValid = this.editMutualValidation.datetime; + this.editMutualValidation.datetime = true; + if (dateTimeIsSameOrAfter(moment().tz(this.currentPolicy.schedules.timezone). + format(AutoscalerConstants.MomentFormateDateTimeT), control.value)) { + errors.alertInvalidPolicyScheduleEndDateTimeBeforeNow = { value: control.value }; + } + if (dateTimeIsSameOrAfter(this.editSpecificDateForm.get('start_date_time').value, control.value)) { + this.editMutualValidation.datetime = false; + errors.alertInvalidPolicyScheduleEndDateTimeBeforeStartDateTime = { value: control.value }; + } + if (specificDateRangeOverlapping(newSchedule, this.editIndex, this.currentPolicy.schedules.specific_date)) { + this.editMutualValidation.datetime = false; + errors.alertInvalidPolicyScheduleSpecificConflict = { value: control.value }; + } + if (this.editSpecificDateForm && lastValid !== this.editMutualValidation.datetime) { + this.editSpecificDateForm.controls.start_date_time.updateValueAndValidity(); + } + return Object.keys(errors).length === 0 ? null : errors; + }; + } + + validateGlobalSetting() { + return this.currentPolicy.scaling_rules_form.length === 0 + && this.currentPolicy.schedules.recurring_schedule.length === 0 + && this.currentPolicy.schedules.specific_date.length === 0; + } +} + +export function validateRecurringSpecificMin(editForm, editMutualValidation): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = editForm && + numberWithFractionOrExceedRange(control.value, 1, editForm.get('instance_max_count').value - 1, true); + const lastValid = editMutualValidation.limit; + editMutualValidation.limit = editForm && control.value < editForm.get('instance_max_count').value; + if (editForm && lastValid !== editMutualValidation.limit) { + editForm.controls.instance_max_count.updateValueAndValidity(); + } + if (editForm) { + editForm.controls.initial_min_instance_count.updateValueAndValidity(); + } + return invalid ? { alertInvalidPolicyMinimumRange: { value: control.value } } : null; + }; +} + +export function validateRecurringSpecificMax(editForm, editMutualValidation): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const invalid = editForm && numberWithFractionOrExceedRange(control.value, + editForm.get('instance_min_count').value + 1, Number.MAX_VALUE, true); + const lastValid = editMutualValidation.limit; + editMutualValidation.limit = editForm && editForm.get('instance_min_count').value < control.value; + if (editForm && lastValid !== editMutualValidation.limit) { + editForm.controls.instance_min_count.updateValueAndValidity(); + } + if (editForm) { + editForm.controls.initial_min_instance_count.updateValueAndValidity(); + } + return invalid ? { alertInvalidPolicyMaximumRange: { value: control.value } } : null; + }; +} diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.html b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.html new file mode 100644 index 0000000000..adc4db13ee --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.html @@ -0,0 +1,25 @@ +
+ +

Edit AutoScaler Policy: {{ (applicationService.application$ | async)?.app.entity.name }}

+
+ +
+
+ + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.scss b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.scss new file mode 100644 index 0000000000..7b687f60c5 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.scss @@ -0,0 +1,16 @@ +.autoscaler-policy-edit-steppers { + height: 100%; + app-edit-autoscaler-policy-step1 { + width: 50%; + } + app-edit-autoscaler-policy-step2 { + width: 100%; + } + app-edit-autoscaler-policy-step3 { + width: 100%; + } + app-edit-autoscaler-policy-step4 { + width: 100%; + } +} + diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.spec.ts new file mode 100644 index 0000000000..7069cac31b --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.spec.ts @@ -0,0 +1,60 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../cf-autoscaler-testing.module'; +import { EditAutoscalerPolicyComponent } from './edit-autoscaler-policy.component'; +import { EditAutoscalerPolicyStep1Component } from './edit-autoscaler-policy-step1/edit-autoscaler-policy-step1.component'; +import { EditAutoscalerPolicyStep2Component } from './edit-autoscaler-policy-step2/edit-autoscaler-policy-step2.component'; +import { EditAutoscalerPolicyStep3Component } from './edit-autoscaler-policy-step3/edit-autoscaler-policy-step3.component'; +import { EditAutoscalerPolicyStep4Component } from './edit-autoscaler-policy-step4/edit-autoscaler-policy-step4.component'; +import { EditAutoscalerPolicyService } from './edit-autoscaler-policy-service'; + +describe('EditAutoscalerPolicyComponent', () => { + let component: EditAutoscalerPolicyComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + EditAutoscalerPolicyComponent, + EditAutoscalerPolicyStep1Component, + EditAutoscalerPolicyStep2Component, + EditAutoscalerPolicyStep3Component, + EditAutoscalerPolicyStep4Component, + ], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService, + EditAutoscalerPolicyService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditAutoscalerPolicyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts new file mode 100644 index 0000000000..1af999f6b9 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, publishReplay, refCount } from 'rxjs/operators'; +import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material'; + +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { EditAutoscalerPolicyService } from './edit-autoscaler-policy-service'; + +@Component({ + selector: 'app-edit-autoscaler-policy', + templateUrl: './edit-autoscaler-policy.component.html', + styleUrls: ['./edit-autoscaler-policy.component.scss'], + providers: [ + { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher }, + EditAutoscalerPolicyService + ] +}) +export class EditAutoscalerPolicyComponent implements OnInit { + + parentUrl = `/applications/${this.applicationService.cfGuid}/${this.applicationService.appGuid}/autoscale`; + applicationName$: Observable; + + constructor( + public applicationService: ApplicationService, + ) { + } + + ngOnInit() { + this.applicationName$ = this.applicationService.app$.pipe( + map(({ entity }) => entity ? entity.entity.name : null), + publishReplay(1), + refCount() + ); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/public_api.ts b/src/frontend/packages/cf-autoscaler/src/public_api.ts new file mode 100644 index 0000000000..24160374d0 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/public_api.ts @@ -0,0 +1,6 @@ +/* + * Public API Surface of cloud-foundry + */ + +// export * from './lib/cloud-foundry.service'; +export * from './lib/cf-autoscaler.module'; diff --git a/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.html b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.html new file mode 100644 index 0000000000..0dc89a73d4 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.html @@ -0,0 +1,48 @@ + +
+ + Instances + + + + +
+
+ + Limits + + + + +
+ + + +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.scss b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.scss new file mode 100644 index 0000000000..03cff2642b --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.scss @@ -0,0 +1,52 @@ +.card-autoscaler-default { + &__left { + border-right: 1px solid #808080; + display: inline-block; + padding-right: 1em; + } + + &__right { + display: inline-block; + padding-left: 1em; + } + + &__actions { + bottom: 24px; + padding-top: 0; + position: absolute; + right: 24px; + text-align: right; + } + + app-metadata-item { + .metadata-item__content { + flex-direction: row; + } + } + + .metadata-item__content { + display: inline; + } + + .metadata-item__value { + display: inline; + margin-right: 10px; + } + + .metadata-item__label { + display: inline; + margin-right: 10px; + } +} + +.app-metadata { + display: flex; + flex-direction: row; + &__two-cols { + flex: 1; + margin-right: 1em; + app-metadata-item:first-child { + margin-top: 0; + } + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.spec.ts new file mode 100644 index 0000000000..b21958943d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.spec.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { CoreModule } from '../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { ApplicationStateService } from '../../../../core/src/shared/components/application-state/application-state.service'; +import { MetadataItemComponent } from '../../../../core/src/shared/components/metadata-item/metadata-item.component'; +import { + RunningInstancesComponent, +} from '../../../../core/src/shared/components/running-instances/running-instances.component'; +import { EntityMonitorFactory } from '../../../../core/src/shared/monitors/entity-monitor.factory.service'; +import { PaginationMonitorFactory } from '../../../../core/src/shared/monitors/pagination-monitor.factory'; +import { ApplicationServiceMock } from '../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../cf-autoscaler-testing.module'; +import { CardAutoscalerDefaultComponent } from './card-autoscaler-default.component'; + +describe('CardAutoscalerDefaultComponent', () => { + let component: CardAutoscalerDefaultComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + CardAutoscalerDefaultComponent, + MetadataItemComponent, + RunningInstancesComponent, + ], + imports: [ + CoreModule, + CommonModule, + BrowserAnimationsModule, + createBasicStoreModule(), + CfAutoscalerTestingModule + ], + providers: [ + { provide: ApplicationService, useClass: ApplicationServiceMock }, + ApplicationStateService, + EntityMonitorFactory, + PaginationMonitorFactory, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CardAutoscalerDefaultComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts new file mode 100644 index 0000000000..9b85a65095 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts @@ -0,0 +1,58 @@ +import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, publishReplay, refCount } from 'rxjs/operators'; + +import { EntityService } from '../../../../core/src/core/entity-service'; +import { EntityServiceFactory } from '../../../../core/src/core/entity-service-factory.service'; +import { ApplicationService } from '../../../../core/src/features/applications/application.service'; +import { entityFactory } from '../../../../store/src/helpers/entity-factory'; +import { APIResource } from '../../../../store/src/types/api.types'; +import { GetAppAutoscalerPolicyAction } from '../../store/app-autoscaler.actions'; +import { AppAutoscalerPolicyLocal } from '../../store/app-autoscaler.types'; +import { appAutoscalerPolicySchemaKey } from '../../store/autoscaler.store.module'; + + +@Component({ + selector: 'app-card-autoscaler-default', + templateUrl: './card-autoscaler-default.component.html', + styleUrls: ['./card-autoscaler-default.component.scss'] +}) +export class CardAutoscalerDefaultComponent implements OnInit { + + @ViewChild('instanceField') instanceField: ElementRef; + + constructor( + public appService: ApplicationService, + private entityServiceFactory: EntityServiceFactory, + private applicationService: ApplicationService, + ) { + } + + appAutoscalerPolicyService: EntityService; + appAutoscalerPolicy$: Observable>; + applicationInstances$: Observable; + + @Input() + onUpdate: () => void = () => { } + + ngOnInit() { + this.appAutoscalerPolicyService = this.entityServiceFactory.create>( + appAutoscalerPolicySchemaKey, + entityFactory(appAutoscalerPolicySchemaKey), + this.applicationService.appGuid, + new GetAppAutoscalerPolicyAction(this.applicationService.appGuid, this.applicationService.cfGuid), + false + ); + this.appAutoscalerPolicy$ = this.appAutoscalerPolicyService.entityObs$.pipe( + map(({ entity }) => { + return entity && entity.entity; + }) + ); + this.applicationInstances$ = this.applicationService.app$.pipe( + map(({ entity }) => entity ? entity.entity.instances : null), + publishReplay(1), + refCount() + ); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service.spec.ts new file mode 100644 index 0000000000..651ce2e852 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service.spec.ts @@ -0,0 +1,54 @@ +import { CommonModule } from '@angular/common'; +import { inject, TestBed } from '@angular/core/testing'; +import { ConnectionBackend, Http } from '@angular/http'; +import { MockBackend } from '@angular/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule } from '../../../../../core/src/core/core.module'; +import { EntityServiceFactory } from '../../../../../core/src/core/entity-service-factory.service'; +import { ApplicationsModule } from '../../../../../core/src/features/applications/applications.module'; +import { SharedModule } from '../../../../../core/src/shared/shared.module'; +import { generateTestApplicationServiceProvider } from '../../../../../core/test-framework/application-service-helper'; +import { generateTestEntityServiceProvider } from '../../../../../core/test-framework/entity-service.helper'; +import { createBasicStoreModule, getInitialTestStoreState } from '../../../../../core/test-framework/store-test-helper'; +import { GetApplication } from '../../../../../store/src/actions/application.actions'; +import { applicationSchemaKey, entityFactory } from '../../../../../store/src/helpers/entity-factory'; +import { endpointStoreNames } from '../../../../../store/src/types/endpoint.types'; +import { CfAutoscalerTestingModule } from '../../../cf-autoscaler-testing.module'; +import { CfAppAutoscalerEventsConfigService } from './cf-app-autoscaler-events-config.service'; + +describe('CfAppAutoscalerEventsConfigService', () => { + const initialState = getInitialTestStoreState(); + + const cfGuid = Object.keys(initialState.requestData[endpointStoreNames.type])[0]; + const appGuid = Object.keys(initialState.requestData.application)[0]; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CfAppAutoscalerEventsConfigService, + EntityServiceFactory, + generateTestEntityServiceProvider( + appGuid, + entityFactory(applicationSchemaKey), + new GetApplication(appGuid, cfGuid) + ), + generateTestApplicationServiceProvider(appGuid, cfGuid), + Http, + { provide: ConnectionBackend, useClass: MockBackend }, + ], + imports: [ + CommonModule, + CoreModule, + SharedModule, + ApplicationsModule, + createBasicStoreModule(), + RouterTestingModule, + CfAutoscalerTestingModule + ] + }); + }); + + it('should be created', inject([CfAppAutoscalerEventsConfigService], (service: CfAppAutoscalerEventsConfigService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service.ts new file mode 100644 index 0000000000..4f888d39b9 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-config.service.ts @@ -0,0 +1,145 @@ +import { DatePipe } from '@angular/common'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import * as moment from 'moment'; + +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { IListConfig, ListConfig, ListViewTypes } from '../../../../../core/src/shared/components/list/list.component.types'; +import { MetricsRangeSelectorService } from '../../../../../core/src/shared/services/metrics-range-selector.service'; +import { ITimeRange, MetricQueryType } from '../../../../../core/src/shared/services/metrics-range-selector.types'; +import { AppState } from '../../../../../store/src/app-state'; +import { APIResource } from '../../../../../store/src/types/api.types'; +import { AppAutoscalerEvent } from '../../../store/app-autoscaler.types'; +import { CfAppAutoscalerEventsDataSource } from './cf-app-autoscaler-events-data-source'; +import { + TableCellAutoscalerEventChangeComponent, +} from './table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component'; +import { + TableCellAutoscalerEventStatusComponent, +} from './table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component'; + +@Injectable() +export class CfAppAutoscalerEventsConfigService + extends ListConfig> + implements IListConfig> { + autoscalerEventSource: CfAppAutoscalerEventsDataSource; + columns: Array>> = [ + { + columnId: 'timestamp', + headerCell: () => 'Timestamp', + cellDefinition: { + getValue: row => this.datePipe.transform(row.entity.timestamp / 1000000, 'medium') + }, + sort: true, + cellFlex: '3' + }, + { + columnId: 'status', headerCell: () => 'Status', cellComponent: TableCellAutoscalerEventStatusComponent, cellFlex: '2' + }, + { + columnId: 'type', + headerCell: () => 'Type', + cellDefinition: { + getValue: row => row.entity.scaling_type === 0 ? 'dynamic' : 'schedule' + }, + cellFlex: '2' + }, + { + columnId: 'change', headerCell: () => 'Instance Change', cellComponent: TableCellAutoscalerEventChangeComponent, cellFlex: '2' + }, + { + columnId: 'action', + headerCell: () => 'Action', + cellDefinition: { + getValue: row => { + if (row.entity.message) { + const change = row.entity.new_instances - row.entity.old_instances; + if (change >= 0) { + return '+' + change + ' instance(s) because ' + row.entity.message; + } else { + return change + ' instance(s) because ' + row.entity.message; + } + } else { + return row.entity.reason; + } + } + }, + cellFlex: '4' + }, + { + columnId: 'error', + headerCell: () => 'Error', + cellDefinition: { + valuePath: 'entity.error' + }, + cellFlex: '4' + }, + ]; + viewType = ListViewTypes.TABLE_ONLY; + text = { + title: null, + noEntries: 'There are no scaling events' + }; + isLocal = false; + + showCustomTime = true; + customTimePollingInterval = 120000; + customTimeInitialValue = '1:month'; + customTimeWindows: ITimeRange[] = [ + { + value: '1:day', + label: 'The past day', + queryType: MetricQueryType.QUERY + }, + { + value: '1:week', + label: 'The past week', + queryType: MetricQueryType.QUERY + }, + { + value: '1:month', + label: 'The past month', + queryType: MetricQueryType.QUERY + }, + { + label: 'Custom time window', + queryType: MetricQueryType.RANGE_QUERY + } + ]; + + private thirtyDays = 1000 * 60 * 60 * 24 * 30; + customTimeValidation = (start: moment.Moment, end: moment.Moment) => { + if (!end || !start) { + return null; + } + if (!start.isBefore(end)) { + return 'Start date must be before end date.'; + } + if (moment().diff(start) > this.thirtyDays) { + return 'Only recent 30 days data are support to be query.'; + } + } + + constructor( + private store: Store, + private appService: ApplicationService, + private datePipe: DatePipe, + metricsRangeService: MetricsRangeSelectorService) { + super(); + this.autoscalerEventSource = new CfAppAutoscalerEventsDataSource( + this.store, + this.appService.cfGuid, + this.appService.appGuid, + this, + metricsRangeService + ); + } + + getGlobalActions = () => null; + getMultiActions = () => null; + getSingleActions = () => null; + getColumns = () => this.columns; + getDataSource = () => this.autoscalerEventSource; + getMultiFiltersConfigs = () => []; +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-data-source.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-data-source.ts new file mode 100644 index 0000000000..1f59912244 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/cf-app-autoscaler-events-data-source.ts @@ -0,0 +1,44 @@ +import { Store } from '@ngrx/store'; + +import { getRowMetadata } from '../../../../../core/src/features/cloud-foundry/cf.helpers'; +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { MetricsRangeSelectorService } from '../../../../../core/src/shared/services/metrics-range-selector.service'; +import { AppState } from '../../../../../store/src/app-state'; +import { entityFactory } from '../../../../../store/src/helpers/entity-factory'; +import { APIResource } from '../../../../../store/src/types/api.types'; +import { GetAppAutoscalerScalingHistoryAction } from '../../../store/app-autoscaler.actions'; +import { AppAutoscalerEvent } from '../../../store/app-autoscaler.types'; +import { appAutoscalerScalingHistorySchemaKey } from '../../../store/autoscaler.store.module'; + + +export class CfAppAutoscalerEventsDataSource extends ListDataSource> { + action: any; + constructor( + store: Store, + cfGuid: string, + appGuid: string, + listConfig: IListConfig>, + metricsRangeService: MetricsRangeSelectorService + ) { + const action = new GetAppAutoscalerScalingHistoryAction(null, appGuid, cfGuid); + super( + { + store, + action, + schema: entityFactory(appAutoscalerScalingHistorySchemaKey), + getRowUniqueId: getRowMetadata, + paginationKey: action.paginationKey, + isLocal: false, + listConfig, + refresh: () => { + if (this.metricsAction.windowValue) { + this.metricsAction = metricsRangeService.getNewTimeWindowAction(this.metricsAction, this.metricsAction.windowValue); + } + this.store.dispatch(this.metricsAction); + } + } + ); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change-icon.pipe.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change-icon.pipe.ts new file mode 100644 index 0000000000..a71f4bfa30 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change-icon.pipe.ts @@ -0,0 +1,31 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'tableCellAutoscalerEventChangeIcon' +}) +export class TableCellAutoscalerEventChangeIconPipe implements PipeTransform { + + private result(outputType: string, value: number, cssClass: string, icon: string) { + switch (outputType) { + case 'class': + return cssClass; + case 'icon': + return icon; + default: + return ''; + } + } + + transform(value: number, args?: string): string { + if (!args || !args.length) { + return ''; + } + if (value > 0) { + return this.result(args, value, 'text-danger', 'trending_up'); + } else if (value < 0) { + return this.result(args, value, 'text-success', 'trending_down'); + } else { + return this.result(args, value, 'text-tentative', 'trending_flat'); + } + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.html b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.html new file mode 100644 index 0000000000..cde0f6d000 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.html @@ -0,0 +1,6 @@ +
+ {{row.entity.old_instances}} + + {{row.entity.new_instances-row.entity.old_instances | tableCellAutoscalerEventChangeIcon:'icon'}} + {{row.entity.new_instances}} +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.scss b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.scss new file mode 100644 index 0000000000..7585cbff9a --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.scss @@ -0,0 +1,5 @@ +.app-autoscaler-event-change { + align-items: center; + display: flex; + flex-direction: row; +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.spec.ts new file mode 100644 index 0000000000..f9ee28447f --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatIcon } from '@angular/material'; + +import { EntityInfo } from '../../../../../../store/src/types/api.types'; +import { TableCellAutoscalerEventChangeIconPipe } from './table-cell-autoscaler-event-change-icon.pipe'; +import { TableCellAutoscalerEventChangeComponent } from './table-cell-autoscaler-event-change.component'; + +describe('TableCellAutoscalerEventChangeComponent', () => { + let component: TableCellAutoscalerEventChangeComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [TableCellAutoscalerEventChangeComponent, MatIcon, TableCellAutoscalerEventChangeIconPipe] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TableCellAutoscalerEventChangeComponent); + component = fixture.componentInstance; + component.row = { + entity: { + type: '' + } + } as EntityInfo; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.ts new file mode 100644 index 0000000000..284ee793f1 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-change/table-cell-autoscaler-event-change.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; + +@Component({ + selector: 'app-table-cell-autoscaler-event-change', + templateUrl: './table-cell-autoscaler-event-change.component.html', + styleUrls: ['./table-cell-autoscaler-event-change.component.scss'] +}) +export class TableCellAutoscalerEventChangeComponent extends TableCellCustom { } diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status-icon.pipe.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status-icon.pipe.ts new file mode 100644 index 0000000000..cd6217d3fd --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status-icon.pipe.ts @@ -0,0 +1,38 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'tableCellAutoscalerEventStatusIcon' +}) +export class TableCellAutoscalerEventStatusIconPipe implements PipeTransform { + + private result(outputType: string, value: number, cssClass: string, icon: string, label: string) { + switch (outputType) { + case 'class': + return cssClass; + case 'icon': + return icon; + case 'label': + return label; + default: + return ''; + } + } + + transform(value: number, args?: string): string { + if (!args || !args.length) { + return ''; + } + switch (value) { + case 0: + return this.result(args, value, 'text-success', 'lens', 'succeeded'); + case 1: + return this.result(args, value, 'text-danger', 'warning', 'failed'); + case 2: + return this.result(args, value, 'text-tentative', 'broken_image', 'ignored'); + default: + return ''; + } + + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.html b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.html new file mode 100644 index 0000000000..d295c4f14c --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.html @@ -0,0 +1,5 @@ +
+ + {{row.entity.status | tableCellAutoscalerEventStatusIcon:'icon'}} + {{row.entity.status | tableCellAutoscalerEventStatusIcon:'label'}} +
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.scss b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.scss new file mode 100644 index 0000000000..195f5260cb --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.scss @@ -0,0 +1,12 @@ +.app-autoscaler-event-status { + align-items: center; + display: flex; + flex-direction: row; + .mat-icon { + height: 14px; + width: 14px; + } + .material-icons { + font-size: 14px; + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.spec.ts new file mode 100644 index 0000000000..08e1647b35 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatIcon } from '@angular/material'; + +import { EntityInfo } from '../../../../../../store/src/types/api.types'; +import { TableCellAutoscalerEventStatusIconPipe } from './table-cell-autoscaler-event-status-icon.pipe'; +import { TableCellAutoscalerEventStatusComponent } from './table-cell-autoscaler-event-status.component'; + +describe('TableCellAutoscalerEventStatusComponent', () => { + let component: TableCellAutoscalerEventStatusComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [TableCellAutoscalerEventStatusComponent, MatIcon, TableCellAutoscalerEventStatusIconPipe] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TableCellAutoscalerEventStatusComponent); + component = fixture.componentInstance; + component.row = { + entity: { + type: '' + } + } as EntityInfo; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.ts new file mode 100644 index 0000000000..ab8cf4d7e7 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-event/table-cell-autoscaler-event-status/table-cell-autoscaler-event-status.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { EntityInfo } from '../../../../../../store/src/types/api.types'; + +@Component({ + selector: 'app-table-cell-autoscaler-event-status', + templateUrl: './table-cell-autoscaler-event-status.component.html', + styleUrls: ['./table-cell-autoscaler-event-status.component.scss'] +}) +export class TableCellAutoscalerEventStatusComponent extends TableCellCustom { } diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.html b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.html new file mode 100644 index 0000000000..8496cb9450 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.html @@ -0,0 +1,31 @@ + + + + {{metricType}}
+
+ {{paramsMetricsStart | date:'medium'}} ~ + {{paramsMetricsEnd | date:'medium'}} +
+
+
+ +
+ + +
+
+ + +
+
+
\ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.scss b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.scss new file mode 100644 index 0000000000..002bddcdbd --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.scss @@ -0,0 +1,26 @@ +mat-card-header { + .autoscaler-metric-subtitle { + color: rgba(0, 0, 0, .54); + font-size: .9em; + margin-top: .3em; + } +} + +.autoscaler-chart-card { + display: flex; + height: 250px; + width: 100%; + + .autoscaler-chart-gauge { + height: 200px; + margin: 40px 20px 0 0; + width: 20%; + ngx-charts-gauge { + width: 100%; + } + } + .autoscaler-chart-combo { + margin-top: 1em; + width: 80%; + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.spec.ts new file mode 100644 index 0000000000..280d49798e --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.spec.ts @@ -0,0 +1,71 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { + ApplicationEnvVarsHelper, +} from '../../../../../../core/src/features/applications/application/application-tabs-base/tabs/build-tab/application-env-vars.service'; +import { + ApplicationStateService, +} from '../../../../../../core/src/shared/components/application-state/application-state.service'; +import { ConfirmationDialogService } from '../../../../../../core/src/shared/components/confirmation-dialog.service'; +import { ServiceActionHelperService } from '../../../../../../core/src/shared/data-services/service-action-helper.service'; +import { EntityMonitorFactory } from '../../../../../../core/src/shared/monitors/entity-monitor.factory.service'; +import { PaginationMonitorFactory } from '../../../../../../core/src/shared/monitors/pagination-monitor.factory'; +import { generateTestApplicationServiceProvider } from '../../../../../../core/test-framework/application-service-helper'; +import { BaseTestModules } from '../../../../../../core/test-framework/cloud-foundry-endpoint-service.helper'; +import { AppAutoscalerMetricChartCardComponent } from './app-autoscaler-metric-chart-card.component'; +import { AppAutoscalerComboChartComponent } from './combo-chart/combo-chart.component'; +import { AppAutoscalerComboSeriesVerticalComponent } from './combo-chart/combo-series-vertical.component'; + +describe('AppAutoscalerMetricChartCardComponent', () => { + let component: AppAutoscalerMetricChartCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + AppAutoscalerMetricChartCardComponent, + AppAutoscalerComboChartComponent, + AppAutoscalerComboSeriesVerticalComponent + ], + imports: [ + ...BaseTestModules, + NgxChartsModule + ], + providers: [ + EntityMonitorFactory, + generateTestApplicationServiceProvider('1', '1'), + ApplicationEnvVarsHelper, + ApplicationStateService, + PaginationMonitorFactory, + ConfirmationDialogService, + DatePipe, + ServiceActionHelperService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AppAutoscalerMetricChartCardComponent); + component = fixture.componentInstance; + component.row = { + entity: { + upper: [], + lower: [], + }, + metadata: { + guid: '', + created_at: '', + updated_at: '', + url: '' + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.ts new file mode 100644 index 0000000000..a8c1d94480 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component.ts @@ -0,0 +1,122 @@ +import { Component, Input } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { ApplicationService } from '../../../../../../core/src/features/applications/application.service'; +import { CardCell, IListRowCell } from '../../../../../../core/src/shared/components/list/list.types'; +import { PaginationMonitorFactory } from '../../../../../../core/src/shared/monitors/pagination-monitor.factory'; +import { AppState } from '../../../../../../store/src/app-state'; +import { entityFactory } from '../../../../../../store/src/helpers/entity-factory'; +import { getPaginationObservables } from '../../../../../../store/src/reducers/pagination-reducer/pagination-reducer.helper'; +import { APIResource } from '../../../../../../store/src/types/api.types'; +import { AutoscalerConstants, buildLegendData } from '../../../../core/autoscaler-helpers/autoscaler-util'; +import { AutoscalerPaginationParams, GetAppAutoscalerAppMetricAction } from '../../../../store/app-autoscaler.actions'; +import { + AppAutoscalerMetricData, + AppAutoscalerMetricDataPoint, + AppScalingTrigger, +} from '../../../../store/app-autoscaler.types'; +import { appAutoscalerAppMetricSchemaKey } from '../../../../store/autoscaler.store.module'; + + +@Component({ + selector: 'app-app-autoscaler-metric-chart-card', + templateUrl: './app-autoscaler-metric-chart-card.component.html', + styleUrls: ['./app-autoscaler-metric-chart-card.component.scss'] +}) + +export class AppAutoscalerMetricChartCardComponent extends CardCell> implements IListRowCell { + static columns = 1; + listData: { + label: string; + data$: Observable; + customStyle?: string; + }[]; + + envVarUrl: string; + + comboBarScheme = { + name: 'singleLightBlue', + selectable: true, + group: 'Ordinal', + domain: ['#01579b'] + }; + lineChartScheme = { + name: 'coolthree', + selectable: true, + group: 'Ordinal', + domain: ['#01579b'] + }; + + public paramsMetricsEnd: number = (new Date()).getTime(); + public paramsMetricsStart: number = this.paramsMetricsEnd - 30 * 60 * 1000; + public paramsMetrics: AutoscalerPaginationParams = { + 'start-time': this.paramsMetricsStart + '000000', + 'end-time': this.paramsMetricsEnd + '000000', + page: '1', + 'results-per-page': '10000000', + 'order-direction': 'asc' + }; + + public metricType: string; + + @Input('row') + set row(row: APIResource) { + if (row) { + if (row.entity.query && row.entity.query.params) { + this.paramsMetricsStart = row.entity.query.params.start * 1000; + this.paramsMetricsEnd = row.entity.query.params.end * 1000; + this.paramsMetrics['start-time'] = this.paramsMetricsStart + '000000'; + this.paramsMetrics['end-time'] = this.paramsMetricsEnd + '000000'; + + this.appAutoscalerAppMetricLegend = this.getLegend2(row.entity); + this.metricType = AutoscalerConstants.getMetricFromMetricId(row.metadata.guid); + this.metricData$ = this.getAppMetric(this.metricType, row.entity, this.paramsMetrics); + } + } + } + + constructor( + private appService: ApplicationService, + private store: Store, + private paginationMonitorFactory: PaginationMonitorFactory, + ) { + super(); + } + + public metricData$: Observable; + public appAutoscalerAppMetricLegend; + + getLegend2(trigger: AppScalingTrigger) { + const legendColor = buildLegendData(trigger); + const legendValue: AppAutoscalerMetricDataPoint[] = []; + legendColor.map((item) => { + legendValue.push({ + name: item.name, + value: 1 + }); + }); + return { + legendValue, + legendColor + }; + } + + getAppMetric(metricName: string, trigger: AppScalingTrigger, params: AutoscalerPaginationParams): Observable { + const action = new GetAppAutoscalerAppMetricAction(this.appService.appGuid, + this.appService.cfGuid, metricName, false, trigger, params); + this.store.dispatch(action); + return getPaginationObservables({ + store: this.store, + action, + paginationMonitor: this.paginationMonitorFactory.create( + action.paginationKey, + entityFactory(appAutoscalerAppMetricSchemaKey) + ) + }, false).entities$.pipe( + filter(entities => !!entities) + ); + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.html b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.html new file mode 100644 index 0000000000..e2605ee693 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.scss b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.scss new file mode 100644 index 0000000000..345a3802c0 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.scss @@ -0,0 +1,85 @@ +.ngx-charts { + float: left; + overflow: visible; + + .circle, + .bar, + .arc { + cursor: pointer; + } + + .bar, + .cell, + .arc, + .card { + &.active, + &:hover { + opacity: .8; + transition: opacity 100ms ease-in-out; + } + + &:focus { + outline: none; + } + } + + g { + &:focus { + outline: none; + } + } + + .line-series, + .line-series-range, + .area-series { + &.inactive { + opacity: .2; + transition: opacity 100ms ease-in-out; + } + } + + .line-highlight { + display: none; + + &.active { + display: block; + } + } + + .area { + opacity: .6; + } + + .circle { + &:hover { + cursor: pointer; + } + } + + .label { + font-size: 12px; + font-weight: normal; + } + + .tooltip-anchor { + fill: rgb(0, 0, 0); + } + + .gridline-path { + fill: none; + stroke: #ddd; + stroke-width: 1; + } + + .grid-panel { + rect { + fill: none; + } + + &.odd { + rect { + fill: rgba(0, 0, 0, .05); + } + } + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.ts new file mode 100644 index 0000000000..99c5c596c9 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-chart.component.ts @@ -0,0 +1,421 @@ +import { + Component, + ContentChild, + EventEmitter, + HostListener, + Input, + Output, + TemplateRef, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { + BaseChartComponent, + calculateViewDimensions, + ColorHelper, + LineSeriesComponent, + ViewDimensions, +} from '@swimlane/ngx-charts'; +import { scaleBand, scaleLinear, scalePoint, scaleTime } from 'd3-scale'; +import { curveLinear } from 'd3-shape'; + +@Component({ + selector: 'app-autoscaler-combo-chart-component', + templateUrl: './combo-chart.component.html', + styleUrls: ['./combo-chart.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class AppAutoscalerComboChartComponent extends BaseChartComponent { + + @ViewChild(LineSeriesComponent) lineSeriesComponent: LineSeriesComponent; + + @Input() curve: any = curveLinear; + @Input() legend = false; + @Input() legendTitle = 'Legend'; + @Input() legendPosition = 'right'; + @Input() xAxis; + @Input() yAxis; + @Input() showXAxisLabel; + @Input() showYAxisLabel; + @Input() xAxisLabel; + @Input() yAxisLabel; + @Input() tooltipDisabled = false; + @Input() gradient: boolean; + @Input() showGridLines = true; + @Input() activeEntries: any[] = []; + @Input() schemeType: string; + @Input() xAxisTickFormatting: any; + @Input() yAxisTickFormatting: any; + @Input() roundDomains = false; + @Input() colorSchemeLine: any[]; + @Input() autoScale; + @Input() lineChart: any; + @Input() yLeftAxisScaleFactor: any; + @Input() rangeFillOpacity: number; + @Input() animations = true; + @Input() yScaleMax: number; + @Input() metricName: string; + @Input() legendData: any[]; + + @Output() activate: EventEmitter = new EventEmitter(); + @Output() deactivate: EventEmitter = new EventEmitter(); + + @ContentChild('tooltipTemplate') tooltipTemplate: TemplateRef; + @ContentChild('seriesTooltipTemplate') seriesTooltipTemplate: TemplateRef; + + dims: ViewDimensions; + xScale: any; + yScale: any; + xDomain: any; + yDomain: any; + transform: string; + colors: ColorHelper; + colorsLine: ColorHelper; + colorsExtra: ColorHelper; + margin: any[] = [10, 20, 10, 20]; + xAxisHeight = 0; + yAxisWidth = 0; + legendOptions: any; + legendOptionsExtra: any; + scaleType = 'linear'; + xScaleLine; + yScaleLine; + xDomainLine; + yDomainLine; + seriesDomain; + scaledAxis; + combinedSeries; + xSet; + filteredDomain; + hoveredVertical; + yOrientLeft = 'left'; + yOrientRight = 'right'; + legendSpacing = 0; + bandwidth; + barPadding = 2; + + trackBy(index, item): string { + return item.name; + } + + update(): void { + super.update(); + if (!this.yAxis) { + this.legendSpacing = 0; + } else { + this.legendSpacing = 50; + } + + this.dims = calculateViewDimensions({ + width: this.legend ? this.width - this.legendSpacing : this.width, + height: this.height, + margins: this.margin, + showXAxis: this.xAxis, + showYAxis: this.yAxis, + xAxisHeight: this.xAxisHeight, + yAxisWidth: this.yAxisWidth, + showXLabel: this.showXAxisLabel, + showYLabel: this.showYAxisLabel, + showLegend: this.legend, + legendType: this.schemeType, + }); + + this.xScale = this.getXScale(); + this.yScale = this.getYScale(); + + // line chart + this.xDomainLine = this.getXDomainLine(); + if (this.filteredDomain) { + this.xDomainLine = this.filteredDomain; + } + + this.yDomainLine = this.getYDomain(); + this.seriesDomain = this.getSeriesDomain(); + + this.xScaleLine = this.getXScaleLine(this.xDomainLine, this.dims.width); + this.yScaleLine = this.getYScaleLine(this.yDomainLine, this.dims.height); + + this.setColors(); + this.legendOptions = this.getLegendOptions(); + this.legendOptionsExtra = this.getLegendOptionsExtra(); + + this.transform = `translate(${this.dims.xOffset} , ${this.margin[0]})`; + } + + deactivateAll() { + this.activeEntries = [...this.activeEntries]; + for (const entry of this.activeEntries) { + this.deactivate.emit({ value: entry, entries: [] }); + } + this.activeEntries = []; + } + + @HostListener('mouseleave') + hideCircles(): void { + this.hoveredVertical = null; + this.deactivateAll(); + } + + updateHoveredVertical(item): void { + this.hoveredVertical = item.value; + this.deactivateAll(); + } + + updateDomain(domain): void { + this.filteredDomain = domain; + this.xDomainLine = this.filteredDomain; + this.xScaleLine = this.getXScaleLine(this.xDomainLine, this.dims.width); + } + + getSeriesDomain(): any[] { + this.combinedSeries = []; + this.combinedSeries.push({ + name: this.metricName, + series: this.results + }); + return this.combinedSeries.map(d => d.name); + } + + isDate(value): boolean { + if (value instanceof Date) { + return true; + } + + return false; + } + + getScaleType(values): string { + let date = true; + let num = true; + + for (const value of values) { + if (!this.isDate(value)) { + date = false; + } + + if (typeof value !== 'number') { + num = false; + } + } + + if (date) { + return 'time'; + } + if (num) { + return 'linear'; + } + return 'ordinal'; + } + + getXDomainLine(): any[] { + let values = []; + + for (const results of this.lineChart) { + for (const d of results.series) { + if (!values.includes(d.name)) { + values.push(d.name); + } + } + } + + this.scaleType = this.getScaleType(values); + let domain = []; + + if (this.scaleType === 'time') { + const min = Math.min(...values); + const max = Math.max(...values); + domain = [min, max]; + } else if (this.scaleType === 'linear') { + values = values.map(v => Number(v)); + const min = Math.min(...values); + const max = Math.max(...values); + domain = [min, max]; + } else { + domain = values; + } + + this.xSet = values; + return domain; + } + + getYDomainLine(): any[] { + const domain = []; + + for (const results of this.lineChart) { + for (const d of results.series) { + if (domain.indexOf(d.value) < 0) { + domain.push(d.value); + } + if (d.min !== undefined && domain.indexOf(d.min) < 0) { + domain.push(d.min); + } + if (d.max !== undefined && domain.indexOf(d.max) < 0) { + domain.push(d.max); + } + } + } + + const min = Math.min(...domain); + const max = this.yScaleMax + ? this.yScaleMax + : Math.max(...domain); + return [min, max]; + } + + getXScaleLine(domain, width): any { + let scale; + if (this.bandwidth === undefined) { + this.bandwidth = (this.dims.width - this.barPadding); + } + + if (this.scaleType === 'time') { + scale = scaleTime() + .range([0, width]) + .domain(domain); + } else if (this.scaleType === 'linear') { + scale = scaleLinear() + .range([0, width]) + .domain(domain); + + if (this.roundDomains) { + scale = scale.nice(); + } + } else if (this.scaleType === 'ordinal') { + scale = scalePoint() + .range([this.bandwidth / 2, width - this.bandwidth / 2]) + .domain(domain); + } + + return scale; + } + + getYScaleLine(domain, height): any { + const scale = scaleLinear() + .range([height, 0]) + .domain(domain); + + return this.roundDomains ? scale.nice() : scale; + } + + getXScale(): any { + this.xDomain = this.getXDomain(); + const spacing = this.xDomain.length / (this.dims.width / this.barPadding + 1); + return scaleBand() + .range([0, this.dims.width]) + .paddingInner(spacing) + .domain(this.xDomain); + } + + getYScale(): any { + this.yDomain = this.getYDomain(); + const scale = scaleLinear() + .range([this.dims.height, 0]) + .domain(this.yDomain); + return this.roundDomains ? scale.nice() : scale; + } + + getXDomain(): any[] { + return this.results.map(d => d.name); + } + + getYDomain() { + const values = this.results.map(d => d.value); + const min = Math.min(0, ...values); + const max = this.yScaleMax + ? Math.max(this.yScaleMax, ...values) + : Math.max(0, ...values); + if (this.yLeftAxisScaleFactor) { + const minMax = this.yLeftAxisScaleFactor(min, max); + return [Math.min(0, minMax.min), minMax.max]; + } else { + return [min, max]; + } + } + + onClick(data) { + this.select.emit(data); + } + + setColors(): void { + let domain; + if (this.schemeType === 'ordinal') { + domain = this.xDomain; + } else { + domain = this.yDomain; + } + this.colors = new ColorHelper(this.scheme, this.schemeType, domain, this.customColors); + this.colorsLine = new ColorHelper(this.colorSchemeLine, this.schemeType, domain, this.customColors); + this.colorsExtra = new ColorHelper(this.scheme, this.schemeType, domain, this.customColors); + } + + getLegendOptions() { + const opts = { + scaleType: this.schemeType, + colors: undefined, + domain: this.seriesDomain, + title: undefined, + position: this.legendPosition + }; + if (opts.scaleType === 'ordinal') { + opts.colors = this.colorsLine; + opts.title = this.legendTitle; + } else { + opts.colors = this.colors.scale; + } + return opts; + } + + getLegendOptionsExtra() { + const opts = { + scaleType: this.schemeType, + colors: this.colorsExtra, + domain: [], + title: this.legendTitle, + position: this.legendPosition + }; + opts.colors.colorDomain = []; + opts.colors.customColors = this.legendData; + this.legendData.map((item) => { + opts.colors.colorDomain.push(item.value); + opts.colors.domain.push(item.name); + opts.domain.push(item.name); + }); + return opts; + } + + updateLineWidth(width): void { + this.bandwidth = width; + } + updateYAxisWidth({ width }): void { + this.yAxisWidth = width + 20; + this.update(); + } + + updateXAxisHeight({ height }): void { + this.xAxisHeight = height; + this.update(); + } + + onActivate(item) { + const idx = this.activeEntries.findIndex(d => { + return d.name === item.name && d.value === item.value && d.series === item.series; + }); + if (idx > -1) { + return; + } + + this.activeEntries = [item, ...this.activeEntries]; + this.activate.emit({ value: item, entries: this.activeEntries }); + } + + onDeactivate(item) { + const idx = this.activeEntries.findIndex(d => { + return d.name === item.name && d.value === item.value && d.series === item.series; + }); + + this.activeEntries.splice(idx, 1); + this.activeEntries = [...this.activeEntries]; + + this.deactivate.emit({ value: item, entries: this.activeEntries }); + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component.spec.ts new file mode 100644 index 0000000000..528fada25f --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component.spec.ts @@ -0,0 +1,50 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { CoreModule } from '../../../../../../../core/src/core/core.module'; +import { ApplicationService } from '../../../../../../../core/src/features/applications/application.service'; +import { SharedModule } from '../../../../../../../core/src/shared/shared.module'; +import { TabNavService } from '../../../../../../../core/tab-nav.service'; +import { ApplicationServiceMock } from '../../../../../../../core/test-framework/application-service-helper'; +import { createBasicStoreModule } from '../../../../../../../core/test-framework/store-test-helper'; +import { CfAutoscalerTestingModule } from '../../../../../cf-autoscaler-testing.module'; +import { AppAutoscalerComboSeriesVerticalComponent } from './combo-series-vertical.component'; + +describe('AppAutoscalerComboSeriesVerticalComponent', () => { + let component: AppAutoscalerComboSeriesVerticalComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AppAutoscalerComboSeriesVerticalComponent], + imports: [ + BrowserAnimationsModule, + createBasicStoreModule(), + CoreModule, + SharedModule, + RouterTestingModule, + CfAutoscalerTestingModule, + NgxChartsModule, + ], + providers: [ + DatePipe, + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AppAutoscalerComboSeriesVerticalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component.ts new file mode 100644 index 0000000000..5c049fe3f1 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-card/combo-chart/combo-series-vertical.component.ts @@ -0,0 +1,210 @@ +import { animate, style, transition, trigger } from '@angular/animations'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; + +import { AppAutoscalerMetricDataLine, AppAutoscalerMetricDataPoint } from '../../../../../store/app-autoscaler.types'; + +function formatLabel(label: any): string { + if (label instanceof Date) { + label = label.toLocaleDateString(); + } else { + label = label.toLocaleString(); + } + + return label; +} + +/* tslint:disable:component-selector */ +@Component({ + selector: 'g[ngx-combo-charts-series-vertical]', + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('animationState', [ + transition('* => void', [ + style({ + opacity: 1, + transform: '*', + }), + animate(500, style({ opacity: 0, transform: 'scale(0)' })) + ]) + ]) + ] +}) +export class AppAutoscalerComboSeriesVerticalComponent implements OnChanges { + + @Input() dims; + @Input() type = 'standard'; + @Input() series; + @Input() seriesLine; + @Input() xScale; + @Input() yScale; + @Input() colors; + @Input() tooltipDisabled = false; + @Input() gradient: boolean; + @Input() activeEntries: AppAutoscalerMetricDataLine[]; + @Input() seriesName: string; + @Input() animations = true; + + @Output() select = new EventEmitter(); + @Output() activate = new EventEmitter(); + @Output() deactivate = new EventEmitter(); + @Output() bandwidth = new EventEmitter(); + + bars: any; + x: any; + y: any; + + ngOnChanges(changes): void { + this.update(); + } + + update(): void { + let width; + if (this.series.length) { + width = this.xScale.bandwidth(); + this.bandwidth.emit(width); + } + + let d0 = 0; + let total; + if (this.type === 'normalized') { + total = this.series.map(d => d.value).reduce((sum, d) => sum + d, 0); + } + + this.bars = this.series.map((d, index) => { + + let value = d.value; + const label = d.name; + const formattedLabel = formatLabel(label); + const roundEdges = this.type === 'standard'; + + const bar: any = { + value, + label, + roundEdges, + data: d, + width, + formattedLabel, + height: 0, + x: 0, + y: 0 + }; + + if (this.type === 'standard') { + bar.height = Math.abs(this.yScale(value) - this.yScale(0)); + bar.x = this.xScale(label); + + if (value < 0) { + bar.y = this.yScale(0); + } else { + bar.y = this.yScale(value); + } + } else if (this.type === 'stacked') { + const offset0 = d0; + const offset1 = offset0 + value; + d0 += value; + + bar.height = this.yScale(offset0) - this.yScale(offset1); + bar.x = 0; + bar.y = this.yScale(offset1); + bar.offset0 = offset0; + bar.offset1 = offset1; + } else if (this.type === 'normalized') { + let offset0 = d0; + let offset1 = offset0 + value; + d0 += value; + + if (total > 0) { + offset0 = (offset0 * 100) / total; + offset1 = (offset1 * 100) / total; + } else { + offset0 = 0; + offset1 = 0; + } + + bar.height = this.yScale(offset0) - this.yScale(offset1); + bar.x = 0; + bar.y = this.yScale(offset1); + bar.offset0 = offset0; + bar.offset1 = offset1; + value = (offset1 - offset0).toFixed(2); + } + + if (this.colors.scaleType === 'ordinal') { + bar.color = this.colors.getColor(label); + } else { + if (this.type === 'standard') { + bar.color = this.colors.getColor(value); + bar.gradientStops = this.colors.getLinearGradientStops(value); + } else { + bar.color = this.colors.getColor(bar.offset1); + bar.gradientStops = this.colors.getLinearGradientStops(bar.offset1, bar.offset0); + } + } + + let tooltipLabel = formattedLabel; + if (this.seriesName) { + tooltipLabel = `${this.seriesName} • ${formattedLabel}`; + } + + this.getSeriesTooltips(this.seriesLine, index); + const lineValue = this.seriesLine[0].series[index].value; + const lineName = this.seriesLine[0].series[index].name; + bar.tooltipText = ` + ${tooltipLabel} + Y1 - ${value.toLocaleString()} • Y2 - ${lineValue.toLocaleString()}% + `; + + return bar; + }); + } + + getSeriesTooltips(seriesLine: AppAutoscalerMetricDataLine[], index): AppAutoscalerMetricDataPoint[] { + return seriesLine.map(d => { + return d.series[index]; + }); + } + + isActive(entry: AppAutoscalerMetricDataLine): boolean { + if (!this.activeEntries) { + return false; + } + const item = this.activeEntries.find(d => { + return entry.name === d.name && entry.series === d.series; + }); + return item !== undefined; + } + + onClick(data): void { + this.select.emit(data); + } + + trackBy(index, bar): string { + return bar.label; + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-data-source.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-data-source.ts new file mode 100644 index 0000000000..fd4d9029e5 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-data-source.ts @@ -0,0 +1,42 @@ +import { Store } from '@ngrx/store'; + +import { getRowMetadata } from '../../../../../core/src/features/cloud-foundry/cf.helpers'; +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { MetricsRangeSelectorService } from '../../../../../core/src/shared/services/metrics-range-selector.service'; +import { AppState } from '../../../../../store/src/app-state'; +import { entityFactory } from '../../../../../store/src/helpers/entity-factory'; +import { APIResource } from '../../../../../store/src/types/api.types'; +import { GetAppAutoscalerPolicyTriggerAction } from '../../../store/app-autoscaler.actions'; +import { AppScalingTrigger } from '../../../store/app-autoscaler.types'; + + +export class AppAutoscalerMetricChartDataSource extends ListDataSource> { + action: any; + constructor( + store: Store, + cfGuid: string, + appGuid: string, + listConfig: IListConfig>, + metricsRangeService: MetricsRangeSelectorService + ) { + const action = new GetAppAutoscalerPolicyTriggerAction(null, appGuid, cfGuid); + super( + { + store, + action, + schema: entityFactory(action.entityKey), + getRowUniqueId: getRowMetadata, + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + refresh: () => { + if (this.metricsAction.windowValue) { + this.metricsAction = metricsRangeService.getNewTimeWindowAction(this.metricsAction, this.metricsAction.windowValue); + } + this.store.dispatch(this.metricsAction); + } + } + ); + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service.spec.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service.spec.ts new file mode 100644 index 0000000000..a3de2640d4 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service.spec.ts @@ -0,0 +1,28 @@ +import { DatePipe } from '@angular/common'; +import { inject, TestBed } from '@angular/core/testing'; + +import { + ApplicationEnvVarsHelper, +} from '../../../../../core/src/features/applications/application/application-tabs-base/tabs/build-tab/application-env-vars.service'; +import { generateTestApplicationServiceProvider } from '../../../../../core/test-framework/application-service-helper'; +import { BaseTestModules } from '../../../../../core/test-framework/cloud-foundry-endpoint-service.helper'; +import { CfAutoscalerTestingModule } from '../../../cf-autoscaler-testing.module'; +import { AppAutoscalerMetricChartListConfigService } from './app-autoscaler-metric-chart-list-config.service'; + +describe('AppAutoscalerMetricChartListConfigService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AppAutoscalerMetricChartListConfigService, + generateTestApplicationServiceProvider('1', '1'), + ApplicationEnvVarsHelper, + DatePipe + ], + imports: [...BaseTestModules, CfAutoscalerTestingModule] + }); + }); + + it('should be created', inject([AppAutoscalerMetricChartListConfigService], (service: AppAutoscalerMetricChartListConfigService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service.ts b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service.ts new file mode 100644 index 0000000000..093ee9a298 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/shared/list-types/app-autoscaler-metric-chart/app-autoscaler-metric-chart-list-config.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import * as moment from 'moment'; + +import { ApplicationService } from '../../../../../core/src/features/applications/application.service'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { BaseCfListConfig } from '../../../../../core/src/shared/components/list/list-types/base-cf/base-cf-list-config'; +import { ListViewTypes } from '../../../../../core/src/shared/components/list/list.component.types'; +import { MetricsRangeSelectorService } from '../../../../../core/src/shared/services/metrics-range-selector.service'; +import { ITimeRange, MetricQueryType } from '../../../../../core/src/shared/services/metrics-range-selector.types'; +import { ListView } from '../../../../../store/src/actions/list.actions'; +import { AppState } from '../../../../../store/src/app-state'; +import { APIResource } from '../../../../../store/src/types/api.types'; +import { AutoscalerConstants } from '../../../core/autoscaler-helpers/autoscaler-util'; +import { AppScalingTrigger } from '../../../store/app-autoscaler.types'; +import { + AppAutoscalerMetricChartCardComponent, +} from './app-autoscaler-metric-chart-card/app-autoscaler-metric-chart-card.component'; +import { AppAutoscalerMetricChartDataSource } from './app-autoscaler-metric-chart-data-source'; + + +@Injectable() +export class AppAutoscalerMetricChartListConfigService extends BaseCfListConfig> { + autoscalerMetricSource: AppAutoscalerMetricChartDataSource; + cardComponent = AppAutoscalerMetricChartCardComponent; + viewType = ListViewTypes.CARD_ONLY; + defaultView = 'cards' as ListView; + columns: Array>> = [ + { + columnId: 'name', + headerCell: () => 'Metric type', + cellDefinition: { + getValue: (row) => AutoscalerConstants.getMetricFromMetricId(row.metadata.guid) + }, + cellFlex: '2' + } + ]; + text = { + title: null, + noEntries: 'There are no metrics defined in the policy' + }; + + showCustomTime = true; + customTimePollingInterval = 60000; + customTimeInitialValue = '30:minute'; + customTimeWindows: ITimeRange[] = [ + { + value: '30:minute', + label: 'The past 30 minutes', + queryType: MetricQueryType.QUERY + }, + { + value: '1:hour', + label: 'The past 1 hour', + queryType: MetricQueryType.QUERY + }, + { + value: '2:hour', + label: 'The past 2 hours', + queryType: MetricQueryType.QUERY + }, + { + label: 'Custom time window', + queryType: MetricQueryType.RANGE_QUERY + } + ]; + + private twoHours = 1000 * 60 * 60 * 2; + customTimeValidation = (start: moment.Moment, end: moment.Moment) => { + if (!end || !start) { + return ' '; + } + if (!start.isBefore(end)) { + return 'Start date must be before end date.'; + } + if (end.diff(start) > this.twoHours) { + return 'Time window must be two hours or less'; + } + } + + constructor( + private store: Store, + private appService: ApplicationService, + metricsRangeService: MetricsRangeSelectorService) { + super(); + this.autoscalerMetricSource = new AppAutoscalerMetricChartDataSource( + this.store, + this.appService.cfGuid, + this.appService.appGuid, + this, + metricsRangeService + ); + } + + getGlobalActions = () => null; + getMultiActions = () => null; + getSingleActions = () => null; + getDataSource = () => this.autoscalerMetricSource; + getMultiFiltersConfigs = () => []; + getColumns = () => this.columns; +} diff --git a/src/frontend/packages/cf-autoscaler/src/store/app-autoscaler.actions.ts b/src/frontend/packages/cf-autoscaler/src/store/app-autoscaler.actions.ts new file mode 100644 index 0000000000..0fa992e75b --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/store/app-autoscaler.actions.ts @@ -0,0 +1,204 @@ +import { RequestOptions } from '@angular/http'; + +import { applicationSchemaKey, entityFactory } from '../../../store/src/helpers/entity-factory'; +import { createEntityRelationPaginationKey } from '../../../store/src/helpers/entity-relations/entity-relations.types'; +import { ApiRequestTypes } from '../../../store/src/reducers/api-request-reducer/request-helpers'; +import { PaginatedAction } from '../../../store/src/types/pagination.types'; +import { IRequestAction } from '../../../store/src/types/request.types'; +import { AppAutoscalerPolicyLocal, AppScalingTrigger } from './app-autoscaler.types'; +import { + appAutoscalerAppMetricSchemaKey, + appAutoscalerHealthSchemaKey, + appAutoscalerPolicySchemaKey, + appAutoscalerPolicyTriggerSchemaKey, + appAutoscalerScalingHistorySchemaKey, +} from './autoscaler.store.module'; + +export const AppAutoscalerPolicyEvents = { + GET_APP_AUTOSCALER_POLICY: '[App Autoscaler] Get autoscaler policy', + GET_APP_AUTOSCALER_POLICY_SUCCESS: '[App Autoscaler] Get autoscaler policy success', + GET_APP_AUTOSCALER_POLICY_FAILED: '[App Autoscaler] Get autoscaler policy failed' +}; + +export const AppAutoscalerPolicyTriggerEvents = { + GET_APP_AUTOSCALER_POLICY: '[App Autoscaler] Get autoscaler policy trigger', + GET_APP_AUTOSCALER_POLICY_SUCCESS: '[App Autoscaler] Get autoscaler policy trigger success', + GET_APP_AUTOSCALER_POLICY_FAILED: '[App Autoscaler] Get autoscaler policy trigger failed' +}; + +export const AppAutoscalerScalingHistoryEvents = { + GET_APP_AUTOSCALER_SCALING_HISTORY: '[App Autoscaler] Get autoscaler scaling history', + GET_APP_AUTOSCALER_SCALING_HISTORY_SUCCESS: '[App Autoscaler] Get autoscaler scaling history success', + GET_APP_AUTOSCALER_SCALING_HISTORY_FAILED: '[App Autoscaler] Get autoscaler scaling history failed' +}; + +export const AppAutoscalerMetricEvents = { + GET_APP_AUTOSCALER_METRIC: '[App Autoscaler] Get autoscaler metric', + GET_APP_AUTOSCALER_METRIC_SUCCESS: '[App Autoscaler] Get autoscaler metric success', + GET_APP_AUTOSCALER_METRIC_FAILED: '[App Autoscaler] Get autoscaler metric failed' +}; + +export const APP_AUTOSCALER_POLICY = '[New App Autoscaler] Fetch policy'; +export const APP_AUTOSCALER_POLICY_TRIGGER = '[New App Autoscaler] Fetch policy trigger'; +export const UPDATE_APP_AUTOSCALER_POLICY = '[New App Autoscaler] Update policy'; +export const DETACH_APP_AUTOSCALER_POLICY = '[New App Autoscaler] Detach policy'; +export const APP_AUTOSCALER_HEALTH = '[New App Autoscaler] Fetch Health'; +export const APP_AUTOSCALER_SCALING_HISTORY = '[New App Autoscaler] Fetch Scaling History'; +export const FETCH_APP_AUTOSCALER_METRIC = '[New App Autoscaler] Fetch Metric'; + +export const UPDATE_APP_AUTOSCALER_POLICY_STEP = '[Edit Autoscaler Policy] Step'; + +export class GetAppAutoscalerHealthAction implements IRequestAction { + constructor( + public guid: string, + public endpointGuid: string, + ) { + } + type = APP_AUTOSCALER_HEALTH; + entity = entityFactory(appAutoscalerHealthSchemaKey); + entityKey = appAutoscalerHealthSchemaKey; +} + +export class GetAppAutoscalerPolicyAction implements IRequestAction { + constructor( + public guid: string, + public endpointGuid: string, + ) { } + type = APP_AUTOSCALER_POLICY; + entity = entityFactory(appAutoscalerPolicySchemaKey); + entityKey = appAutoscalerPolicySchemaKey; +} + +export class UpdateAppAutoscalerPolicyAction implements IRequestAction { + static updateKey = 'Updating-Existing-Application-Policy'; + constructor( + public guid: string, + public endpointGuid: string, + public policy: AppAutoscalerPolicyLocal, + ) { } + type = UPDATE_APP_AUTOSCALER_POLICY; + entityKey = appAutoscalerPolicySchemaKey; +} + +export class DetachAppAutoscalerPolicyAction implements IRequestAction { + static updateKey = 'Detaching-Existing-Application-Policy'; + constructor( + public guid: string, + public endpointGuid: string, + ) { } + type = DETACH_APP_AUTOSCALER_POLICY; + entityKey = appAutoscalerPolicySchemaKey; + requestType: ApiRequestTypes = 'delete'; +} + +export class GetAppAutoscalerPolicyTriggerAction implements PaginatedAction { + constructor( + public paginationKey: string, + public guid: string, + public endpointGuid: string, + public normalFormat?: boolean + ) { + this.paginationKey = this.paginationKey || createEntityRelationPaginationKey(applicationSchemaKey, guid); + } + actions = [ + AppAutoscalerPolicyTriggerEvents.GET_APP_AUTOSCALER_POLICY, + AppAutoscalerPolicyTriggerEvents.GET_APP_AUTOSCALER_POLICY_SUCCESS, + AppAutoscalerPolicyTriggerEvents.GET_APP_AUTOSCALER_POLICY_FAILED + ]; + type = APP_AUTOSCALER_POLICY_TRIGGER; + entity = [entityFactory(appAutoscalerPolicyTriggerSchemaKey)]; + entityKey = appAutoscalerPolicyTriggerSchemaKey; + options: RequestOptions; + query: AutoscalerQuery = { + metric: 'policy' + }; + windowValue: string; +} + +export interface AutoscalerPaginationParams { + 'order-direction-field'?: string; + 'order-direction': 'asc' | 'desc'; + 'results-per-page': string; + 'start-time': string; + 'end-time': string; + 'page'?: string; +} + +export interface AutoscalerQuery { + metric: string; + params?: { + start: number; + end: number + }; +} + +export class GetAppAutoscalerScalingHistoryAction implements PaginatedAction { + private static sortField = 'timestamp'; + constructor( + public paginationKey: string, + public guid: string, + public endpointGuid: string, + public normalFormat?: boolean, + public params?: AutoscalerPaginationParams, + ) { + this.paginationKey = this.paginationKey || createEntityRelationPaginationKey(applicationSchemaKey, guid); + } + actions = [ + AppAutoscalerScalingHistoryEvents.GET_APP_AUTOSCALER_SCALING_HISTORY, + AppAutoscalerScalingHistoryEvents.GET_APP_AUTOSCALER_SCALING_HISTORY_SUCCESS, + AppAutoscalerScalingHistoryEvents.GET_APP_AUTOSCALER_SCALING_HISTORY_FAILED + ]; + type = APP_AUTOSCALER_SCALING_HISTORY; + entity = [entityFactory(appAutoscalerScalingHistorySchemaKey)]; + entityKey = appAutoscalerScalingHistorySchemaKey; + options: RequestOptions; + initialParams: AutoscalerPaginationParams = { + 'order-direction-field': GetAppAutoscalerScalingHistoryAction.sortField, + 'order-direction': 'desc', + 'results-per-page': '5', + 'start-time': '0', + 'end-time': '0', + }; + query: AutoscalerQuery = { + metric: 'history' + }; + windowValue: string; +} + +export abstract class GetAppAutoscalerMetricAction implements PaginatedAction { + constructor( + public guid: string, + public endpointGuid: string, + public metricName: string, + public skipFormat: boolean, + public trigger: AppScalingTrigger, + public params: AutoscalerPaginationParams, + ) { + this.paginationKey = this.paginationKey || createEntityRelationPaginationKey(applicationSchemaKey, guid, metricName); + } + actions = [ + AppAutoscalerMetricEvents.GET_APP_AUTOSCALER_METRIC, + AppAutoscalerMetricEvents.GET_APP_AUTOSCALER_METRIC_SUCCESS, + AppAutoscalerMetricEvents.GET_APP_AUTOSCALER_METRIC_FAILED + ]; + url: string; + type = FETCH_APP_AUTOSCALER_METRIC; + entityKey: string; + paginationKey: string; + initialParams = this.params; +} + +export class GetAppAutoscalerAppMetricAction extends GetAppAutoscalerMetricAction implements PaginatedAction { + constructor( + public guid: string, + public endpointGuid: string, + public metricName: string, + public skipFormat: boolean, + public trigger: AppScalingTrigger, + public params: AutoscalerPaginationParams, + ) { + super(guid, endpointGuid, metricName, skipFormat, trigger, params); + this.url = `apps/${guid}/metric/${metricName}`; + } + entityKey = appAutoscalerAppMetricSchemaKey; +} diff --git a/src/frontend/packages/cf-autoscaler/src/store/app-autoscaler.types.ts b/src/frontend/packages/cf-autoscaler/src/store/app-autoscaler.types.ts new file mode 100644 index 0000000000..d7cb19c63d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/store/app-autoscaler.types.ts @@ -0,0 +1,155 @@ +import { AutoscalerQuery } from './app-autoscaler.actions'; + +export interface AppAutoscalerPolicy { + instance_min_count: number; + instance_max_count: number; + scaling_rules?: AppScalingRule[]; + schedules?: { + timezone: string, + recurring_schedule?: AppRecurringSchedule[], + specific_date?: AppSpecificDate[] + }; +} + +export interface AppSpecificDate { + end_date_time: string; + initial_min_instance_count?: number; + instance_max_count: number; + instance_min_count: number; + start_date_time: string; +} + +export interface AppScalingRule { + adjustment: string; + breach_duration_secs?: number; + color?: string; + cool_down_secs?: number; + metric_type: string; + operator: string; + threshold: number; +} + +export interface AppScalingTrigger { + upper: AppScalingRule[]; + lower: AppScalingRule[]; + query?: AutoscalerQuery; +} + +export interface AppRecurringSchedule { + initial_min_instance_count?: number; + instance_min_count: number; + instance_max_count: number; + start_time: string; + end_time: string; + days_of_month?: number[]; + days_of_week?: number[]; + start_date?: string; + end_date?: string; +} + +export interface AppAutoscalerPolicyLocal extends AppAutoscalerPolicy { + enabled: boolean; + scaling_rules_form: AppScalingRule[]; + scaling_rules_map: { + [metricName: string]: AppScalingTrigger + }; +} + +export interface AppAutoscalerHealth { + entity: { + uptime: number; + }; +} + +export interface AppAutoscalerScalingHistory { + next_url: string; + prev_url: string; + page: number; + resources: AppAutoscalerEvent[]; + total_pages: number; + total_results: number; +} + +export interface AppAutoscalerEvent { + app_id: string; + error: string; + message: string; + new_instances: number; + old_instances: number; + reason: string; + scaling_type: number; + status: number; + timestamp: number; +} + +export interface AppAutoscalerMetricData { + app_id: string; + name: string; + timestamp: number; + unit: string; + value: string; + chartMaxValue?: string; +} + +export interface AppAutoscalerMetricDataLocal { + latest: { + target: AppAutoscalerMetricDataPoint[], + colorTarget: AppAutoscalerMetricDataPoint[] + }; + formated: { + target: AppAutoscalerMetricDataPoint[], + colorTarget: AppAutoscalerMetricDataPoint[] + }; + markline: AppAutoscalerMetricDataLine[]; + unit: string; + chartMaxValue: number; +} + +export interface AppAutoscalerMetricDataPoint { + name: string; + value: number | string; + time?: number; +} + +export interface AppAutoscalerMetricLegend { + name: string; + value: string; +} + +export interface AppAutoscalerMetricDataLine { + name: string; + series: AppAutoscalerMetricDataPoint[]; +} + +export interface AppAutoscalerMetricMapInfo { + unit_internal: string; + interval: number; +} + +export interface AppAutoscalerMetricBasicInfo { + interval: number; + unit: string; + chartMaxValue: number; +} + +export interface AppAutoscalerFetchPolicyFailedResponse { + status: number; + noPolicy: boolean; +} + +export interface AppAutoscalerInvalidPolicyError { + alertInvalidPolicyTriggerThreshold100?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyTriggerThresholdRange?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyTriggerStepRange?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyScheduleDateBeforeNow?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyScheduleEndDateBeforeStartDate?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyScheduleStartDateTimeBeforeNow?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyScheduleEndDateTimeBeforeStartDateTime?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyScheduleSpecificConflict?: AppAutoscalerInvalidPolicyErrorEntity; + alertInvalidPolicyScheduleEndDateTimeBeforeNow?: AppAutoscalerInvalidPolicyErrorEntity; +} + +export interface AppAutoscalerInvalidPolicyErrorEntity { + value?: string | number; +} + diff --git a/src/frontend/packages/cf-autoscaler/src/store/autoscaler.effects.ts b/src/frontend/packages/cf-autoscaler/src/store/autoscaler.effects.ts new file mode 100644 index 0000000000..03e76301f8 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/store/autoscaler.effects.ts @@ -0,0 +1,433 @@ +import { Injectable } from '@angular/core'; +import { Headers, Http, Request, RequestOptions, URLSearchParams } from '@angular/http'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { catchError, mergeMap, withLatestFrom } from 'rxjs/operators'; + +import { environment } from '../../../core/src/environments/environment'; +import { AppState } from '../../../store/src/app-state'; +import { + resultPerPageParam, + resultPerPageParamDefault, +} from '../../../store/src/reducers/pagination-reducer/pagination-reducer.types'; +import { selectPaginationState } from '../../../store/src/selectors/pagination.selectors'; +import { APIResource, NormalizedResponse } from '../../../store/src/types/api.types'; +import { PaginatedAction, PaginationEntityState, PaginationParam } from '../../../store/src/types/pagination.types'; +import { + StartRequestAction, + WrapperRequestActionFailed, + WrapperRequestActionSuccess, +} from '../../../store/src/types/request.types'; +import { buildMetricData } from '../core/autoscaler-helpers/autoscaler-transform-metric'; +import { + autoscalerTransformArrayToMap, + autoscalerTransformMapToArray, +} from '../core/autoscaler-helpers/autoscaler-transform-policy'; +import { AutoscalerConstants } from '../core/autoscaler-helpers/autoscaler-util'; +import { + APP_AUTOSCALER_HEALTH, + APP_AUTOSCALER_POLICY, + APP_AUTOSCALER_POLICY_TRIGGER, + APP_AUTOSCALER_SCALING_HISTORY, + AutoscalerQuery, + DETACH_APP_AUTOSCALER_POLICY, + DetachAppAutoscalerPolicyAction, + FETCH_APP_AUTOSCALER_METRIC, + GetAppAutoscalerHealthAction, + GetAppAutoscalerMetricAction, + GetAppAutoscalerPolicyAction, + GetAppAutoscalerPolicyTriggerAction, + GetAppAutoscalerScalingHistoryAction, + UPDATE_APP_AUTOSCALER_POLICY, + UpdateAppAutoscalerPolicyAction, + AutoscalerPaginationParams, +} from './app-autoscaler.actions'; +import { + AppAutoscalerFetchPolicyFailedResponse, + AppAutoscalerMetricDataLocal, + AppScalingTrigger, + AppAutoscalerMetricData, + AppAutoscalerPolicyLocal, + AppAutoscalerEvent, +} from './app-autoscaler.types'; +import { PaginationResponse } from '../../../store/src/types/api.types'; + +const { proxyAPIVersion } = environment; +const commonPrefix = `/pp/${proxyAPIVersion}/autoscaler`; + +function createAutoscalerRequestMessage(requestType: string, error: { status: string, _body: string }) { + return `Unable to ${requestType}: ${error.status} ${error._body}`; +} + +@Injectable() +export class AutoscalerEffects { + constructor( + private http: Http, + private actions$: Actions, + private store: Store, + ) { } + + @Effect() + fetchAppAutoscalerHealth$ = this.actions$.pipe( + ofType(APP_AUTOSCALER_HEALTH), + mergeMap(action => { + const actionType = 'fetch'; + this.store.dispatch(new StartRequestAction(action, actionType)); + const options = new RequestOptions(); + options.url = `${commonPrefix}/health`; + options.method = 'get'; + options.headers = this.addHeaders(action.endpointGuid); + return this.http + .request(new Request(options)).pipe( + mergeMap(response => { + const healthInfo = response.json(); + const mappedData = { + entities: { [action.entityKey]: {} }, + result: [] + } as NormalizedResponse; + this.transformData(action.entityKey, mappedData, action.guid, healthInfo); + return [ + new WrapperRequestActionSuccess(mappedData, action, actionType) + ]; + }), + catchError(err => [ + new WrapperRequestActionFailed(createAutoscalerRequestMessage('fetch health info', err), action, actionType) + ])); + })); + + @Effect() + updateAppAutoscalerPolicy$ = this.actions$.pipe( + ofType(UPDATE_APP_AUTOSCALER_POLICY), + mergeMap(action => { + const actionType = 'update'; + this.store.dispatch(new StartRequestAction(action, actionType)); + const options = new RequestOptions(); + options.url = `${commonPrefix}/apps/${action.guid}/policy`; + options.method = 'put'; + options.headers = this.addHeaders(action.endpointGuid); + options.body = autoscalerTransformMapToArray(action.policy); + return this.http + .request(new Request(options)).pipe( + mergeMap(response => { + const policyInfo = autoscalerTransformArrayToMap(response.json()); + const mappedData = { + entities: { [action.entityKey]: {} }, + result: [] + } as NormalizedResponse; + this.transformData(action.entityKey, mappedData, action.guid, policyInfo); + return [ + new WrapperRequestActionSuccess(mappedData, action, actionType) + ]; + }), + catchError(err => [ + new WrapperRequestActionFailed(createAutoscalerRequestMessage('update policy', err), action, actionType) + ])); + })); + + @Effect() + getAppAutoscalerPolicy$ = this.actions$.pipe( + ofType(APP_AUTOSCALER_POLICY), + mergeMap(action => this.fetchPolicy(action)) + ); + + @Effect() + detachAppAutoscalerPolicy$ = this.actions$.pipe( + ofType(DETACH_APP_AUTOSCALER_POLICY), + mergeMap(action => { + const actionType = 'delete'; + this.store.dispatch(new StartRequestAction(action, actionType)); + const options = new RequestOptions(); + options.url = `${commonPrefix}/apps/${action.guid}/policy`; + options.method = 'delete'; + options.headers = this.addHeaders(action.endpointGuid); + return this.http + .request(new Request(options)).pipe( + mergeMap(response => { + const mappedData = { + entities: { [action.entityKey]: {} }, + result: [] + } as NormalizedResponse; + this.transformData(action.entityKey, mappedData, action.guid, { enabled: false }); + return [ + new WrapperRequestActionSuccess(mappedData, action, actionType) + ]; + }), + catchError(err => [ + new WrapperRequestActionFailed(createAutoscalerRequestMessage('detach policy', err), action, actionType) + ])); + })); + + @Effect() + fetchAppAutoscalerPolicyTrigger$ = this.actions$.pipe( + ofType(APP_AUTOSCALER_POLICY_TRIGGER), + mergeMap(action => this.fetchPolicy(new GetAppAutoscalerPolicyAction(action.guid, action.endpointGuid), action)) + ); + + @Effect() + fetchAppAutoscalerScalingHistory$ = this.actions$.pipe( + ofType(APP_AUTOSCALER_SCALING_HISTORY), + withLatestFrom(this.store), + mergeMap(([action, state]) => { + const actionType = 'fetch'; + const paginatedAction = action as PaginatedAction; + this.store.dispatch(new StartRequestAction(action, actionType)); + const options = new RequestOptions(); + options.url = `${commonPrefix}/apps/${action.guid}/event`; + options.method = 'get'; + options.headers = this.addHeaders(action.endpointGuid); + // Set params from store + const paginationState = selectPaginationState( + action.entityKey, + paginatedAction.paginationKey, + )(state); + const paginationParams = this.getPaginationParams(paginationState); + paginatedAction.pageNumber = paginationState + ? paginationState.currentPage + : 1; + const { metricConfig, ...trimmedPaginationParams } = paginationParams; + options.params = this.buildParams(action.initialParams, trimmedPaginationParams, action.params); + if (!options.params.has(resultPerPageParam)) { + options.params.set( + resultPerPageParam, + resultPerPageParamDefault.toString(), + ); + } + if (options.params.has('order-direction-field')) { + options.params.delete('order-direction-field'); + } + if (options.params.has('order-direction')) { + options.params.set('order', options.params.get('order-direction')); + options.params.delete('order-direction'); + } + if (metricConfig && metricConfig.params) { + options.params.set('start-time', metricConfig.params.start + '000000000'); + options.params.set('end-time', metricConfig.params.end + '000000000'); + } else if (action.query && action.query.params) { + options.params.set('start-time', action.query.params.start + '000000000'); + options.params.set('end-time', action.query.params.end + '000000000'); + } + return this.http + .request(new Request(options)).pipe( + mergeMap(response => { + const histories = response.json(); + const mappedData = { + entities: { [action.entityKey]: {} }, + result: [] + } as NormalizedResponse; + if (action.normalFormat) { + this.transformData(action.entityKey, mappedData, action.guid, histories); + } else { + this.transformEventData(action.entityKey, mappedData, action.guid, histories); + } + return [ + new WrapperRequestActionSuccess(mappedData, action, actionType, histories.total_results, histories.total_pages) + ]; + }), + catchError(err => [ + new WrapperRequestActionFailed(createAutoscalerRequestMessage('fetch scaling history', err), action, actionType) + ])); + })); + + @Effect() + fetchAppAutoscalerAppMetric$ = this.actions$.pipe( + ofType(FETCH_APP_AUTOSCALER_METRIC), + mergeMap(action => { + const actionType = 'fetch'; + this.store.dispatch(new StartRequestAction(action, actionType)); + const options = new RequestOptions(); + options.url = `${commonPrefix}/${action.url}`; + options.method = 'get'; + options.headers = this.addHeaders(action.endpointGuid); + options.params = this.buildParams(action.initialParams, action.params); + if (options.params.has('order-direction')) { + options.params.set('order', options.params.get('order-direction')); + options.params.delete('order-direction'); + } + return this.http + .request(new Request(options)).pipe( + mergeMap(response => { + const data: PaginationResponse = response.json(); + const mappedData = { + entities: { [action.entityKey]: {} }, + result: [] + } as NormalizedResponse; + this.addMetric( + action.entityKey, mappedData, action.guid, action.metricName, data, parseInt(action.initialParams['start-time'], 10), + parseInt(action.initialParams['end-time'], 10), action.skipFormat, action.trigger); + return [ + new WrapperRequestActionSuccess(mappedData, action, actionType) + ]; + }), + catchError(err => [ + new WrapperRequestActionFailed(createAutoscalerRequestMessage('fetch metrics', err), action, actionType) + ])); + })); + + private fetchPolicy( + getPolicyAction: GetAppAutoscalerPolicyAction, + getPolicyTriggerAction?: GetAppAutoscalerPolicyTriggerAction): Observable { + const actionType = 'fetch'; + this.store.dispatch(new StartRequestAction(getPolicyAction, actionType)); + const options = new RequestOptions(); + options.url = `${commonPrefix}/apps/${getPolicyAction.guid}/policy`; + options.method = 'get'; + options.headers = this.addHeaders(getPolicyAction.endpointGuid); + return this.http + .request(new Request(options)).pipe( + mergeMap(response => { + const policyInfo = autoscalerTransformArrayToMap(response.json()); + const mappedData = { + entities: { [getPolicyAction.entityKey]: {} }, + result: [] + } as NormalizedResponse; + this.transformData(getPolicyAction.entityKey, mappedData, getPolicyAction.guid, policyInfo); + + const res = [ + new WrapperRequestActionSuccess(mappedData, getPolicyAction, actionType) + ]; + + if (getPolicyTriggerAction) { + const mappedPolicyData = { + entities: { [getPolicyTriggerAction.entityKey]: {} }, + result: [] + } as NormalizedResponse; + this.transformTriggerData( + getPolicyTriggerAction.entityKey, + mappedPolicyData, + policyInfo, + getPolicyTriggerAction.query, + getPolicyAction.guid + ); + res.push( + new WrapperRequestActionSuccess( + mappedPolicyData, + getPolicyTriggerAction, + actionType, + Object.keys(policyInfo.scaling_rules_map).length, + 1) + ); + } + return res; + }), + catchError(err => { + const noPolicy = err.status === 404 && err._body === '{}'; + if (noPolicy) { + err._body = 'No policy is defined for this application.'; + } + const response: AppAutoscalerFetchPolicyFailedResponse = { status: err.status, noPolicy }; + + return [ + new WrapperRequestActionFailed(createAutoscalerRequestMessage('fetch policy', err), getPolicyAction, actionType, null, response) + ]; + })); + } + + addMetric( + schemaKey: string, + mappedData: NormalizedResponse>, + appId: string, + metricName: string, + data: PaginationResponse, + startTime: number, + endTime: number, + skipFormat: boolean, + trigger: AppScalingTrigger + ) { + const id = AutoscalerConstants.createMetricId(appId, metricName); + + mappedData.entities[schemaKey][id] = { + entity: buildMetricData(metricName, data, startTime, endTime, skipFormat, trigger), + metadata: { + guid: id, + created_at: null, + updated_at: null, + url: null + } + }; + mappedData.result.push(id); + } + + transformData(key: string, mappedData: NormalizedResponse, appGuid: string, data: any) { + mappedData.entities[key][appGuid] = { + entity: data, + metadata: { + guid: appGuid + } + }; + mappedData.result.push(appGuid); + } + + transformEventData(key: string, mappedData: NormalizedResponse, appGuid: string, data: PaginationResponse) { + mappedData.entities[key] = []; + data.resources.forEach((item) => { + const id = AutoscalerConstants.createMetricId(appGuid, item.timestamp + ''); + mappedData.entities[key][id] = { + entity: item, + metadata: { + created_at: item.timestamp, + guid: id, + updated_at: item.timestamp + } + }; + }); + mappedData.result = Object.keys(mappedData.entities[key]); + } + + transformTriggerData( + key: string, mappedData: NormalizedResponse, data: AppAutoscalerPolicyLocal, query: AutoscalerQuery, appGuid: string) { + mappedData.entities[key] = Object.keys(data.scaling_rules_map).reduce((entity, metricType) => { + const id = AutoscalerConstants.createMetricId(appGuid, metricType); + data.scaling_rules_map[metricType].query = query; + entity[id] = { + entity: data.scaling_rules_map[metricType], + metadata: { + guid: id + } + }; + return entity; + }, []); + mappedData.result = Object.keys(mappedData.entities[key]); + } + + addHeaders(cfGuid: string) { + const headers = new Headers(); + headers.set('x-cap-api-host', 'autoscaler'); + headers.set('x-cap-passthrough', 'true'); + headers.set('x-cap-cnsi-list', cfGuid); + return headers; + } + + buildParams(initialParams: AutoscalerPaginationParams, params?: PaginationParam, paginationParams?: AutoscalerPaginationParams) { + const searchParams = new URLSearchParams(); + if (initialParams) { + Object.keys(initialParams).forEach((key) => { + searchParams.set(key, initialParams[key]); + }); + } + if (params) { + Object.keys(params).forEach((key) => { + searchParams.set(key, params[key]); + }); + } + if (paginationParams) { + Object.keys(paginationParams).forEach((key) => { + searchParams.set(key, paginationParams[key]); + }); + } + return searchParams; + } + + getPaginationParams(paginationState: PaginationEntityState): PaginationParam { + return paginationState + ? { + ...paginationState.params, + q: [ + ...(paginationState.params.q || []) + ], + page: paginationState.currentPage.toString(), + } + : {}; + } + +} diff --git a/src/frontend/packages/cf-autoscaler/src/store/autoscaler.store.module.ts b/src/frontend/packages/cf-autoscaler/src/store/autoscaler.store.module.ts new file mode 100644 index 0000000000..702c816075 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/store/autoscaler.store.module.ts @@ -0,0 +1,50 @@ +import { NgModule } from '@angular/core'; + +import { CoreModule } from '../../../core/src/core/core.module'; +import { StratosExtension } from '../../../core/src/core/extension/extension-service'; +import { ExtensionEntitySchema } from '../../../core/src/core/extension/extension-types'; +import { getAPIResourceGuid } from '../../../store/src/selectors/api.selectors'; + +export const appAutoscalerHealthSchemaKey = 'autoscalerHealth'; +export const appAutoscalerPolicySchemaKey = 'autoscalerPolicy'; +export const appAutoscalerPolicyTriggerSchemaKey = 'autoscalerPolicyTrigger'; +export const appAutoscalerScalingHistorySchemaKey = 'autoscalerScalingHistory'; +export const appAutoscalerAppMetricSchemaKey = 'autoscalerAppMetric'; + +export const autoscalerEntities: ExtensionEntitySchema[] = [ + { + entityKey: appAutoscalerPolicySchemaKey, + definition: {}, + options: { idAttribute: getAPIResourceGuid } + }, + { + entityKey: appAutoscalerPolicyTriggerSchemaKey, + definition: {}, + options: { idAttribute: getAPIResourceGuid } + }, + { + entityKey: appAutoscalerHealthSchemaKey, + definition: {}, + options: { idAttribute: getAPIResourceGuid } + }, + { + entityKey: appAutoscalerScalingHistorySchemaKey, + definition: {}, + options: { idAttribute: getAPIResourceGuid } + }, + { + entityKey: appAutoscalerAppMetricSchemaKey, + definition: {}, + options: { idAttribute: getAPIResourceGuid } + }, +]; + +@StratosExtension({ + entities: autoscalerEntities, +}) +@NgModule({ + imports: [ + CoreModule + ] +}) +export class AutoscalerStoreModule { } diff --git a/src/frontend/packages/cf-autoscaler/src/styles.scss b/src/frontend/packages/cf-autoscaler/src/styles.scss new file mode 100644 index 0000000000..8edf850b82 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/styles.scss @@ -0,0 +1,7 @@ +.autoscaler-policy-edit-specific-left { + .form-field-left { + .mat-form-field-infix { + width: unset; + } + } +} diff --git a/src/frontend/packages/cf-autoscaler/src/test.ts b/src/frontend/packages/cf-autoscaler/src/test.ts new file mode 100644 index 0000000000..18653a67a2 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/test.ts @@ -0,0 +1,36 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files +import 'core-js/es7/reflect'; +import 'zone.js/dist/zone'; +import 'zone.js/dist/zone-testing'; + +import { APP_BASE_HREF } from '@angular/common'; +import { getTestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; + + +declare const require: any; + +// First, initialize the Angular testing environment. +const testBed = getTestBed(); +testBed.initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); + +beforeEach(() => { + testBed.configureTestingModule({ + providers: [{ provide: APP_BASE_HREF, useValue: '/' }] + }); +}); + +/** + * Bump up the Jasmine timeout from 5 seconds + */ +beforeAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; +}); + +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/src/frontend/packages/cf-autoscaler/tsconfig.lib.json b/src/frontend/packages/cf-autoscaler/tsconfig.lib.json new file mode 100644 index 0000000000..29e640092d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.lib.json", + "compilerOptions": { + "outDir": "../../../../out-tsc/lib", + }, + "exclude": [ + "./src/test.ts", + "**/*.spec.ts", + ] +} diff --git a/src/frontend/packages/cf-autoscaler/tsconfig.spec.json b/src/frontend/packages/cf-autoscaler/tsconfig.spec.json new file mode 100644 index 0000000000..cb4a7be918 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.spec.json", + "compilerOptions": { + "outDir": "../../../../out-tsc/spec", + "types": [ + "jasmine", + "node" + ] + }, + "files": [ + "src/test.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/src/frontend/packages/cf-autoscaler/tslint.json b/src/frontend/packages/cf-autoscaler/tslint.json new file mode 100644 index 0000000000..a097b9ebb4 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/tslint.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tslint.json", + "rules": { + "directive-selector": [ + true, + "attribute", + ["app", "lib"], + "camelCase" + ], + "component-selector": [ + true, + "element", + ["app", "lib"], + "kebab-case" + ] + } +} diff --git a/src/frontend/packages/core/src/app.module.ts b/src/frontend/packages/core/src/app.module.ts index 8799281748..cd0722ce94 100644 --- a/src/frontend/packages/core/src/app.module.ts +++ b/src/frontend/packages/core/src/app.module.ts @@ -55,6 +55,7 @@ import { FavoriteConfig, favoritesConfigMapper } from './shared/components/favor import { GlobalEventData, GlobalEventService } from './shared/global-events.service'; import { SharedModule } from './shared/shared.module'; import { XSRFModule } from './xsrf.module'; +import { CfAutoscalerModule } from '../../cf-autoscaler/src/cf-autoscaler.module'; // Create action for router navigation. See // - https://github.com/ngrx/platform/issues/68 @@ -111,7 +112,8 @@ export class CustomRouterStateSerializer AboutModule, CustomImportModule, XSRFModule, - CloudFoundryModule + CloudFoundryModule, + CfAutoscalerModule ], providers: [ TabNavService, diff --git a/src/frontend/packages/core/src/core/entity-service.ts b/src/frontend/packages/core/src/core/entity-service.ts index af81987c98..3d370ac5d0 100644 --- a/src/frontend/packages/core/src/core/entity-service.ts +++ b/src/frontend/packages/core/src/core/entity-service.ts @@ -127,7 +127,8 @@ export class EntityService { }), map(([entityRequestInfo, entity]) => ({ entityRequestInfo, - entity + // If the entity is deleted ensure that we don't pass through a stale state + entity: entityRequestInfo.deleting && entityRequestInfo.deleting.deleted ? null : entity })) ); } diff --git a/src/frontend/packages/core/src/core/extension/dynamic-extension-routes.ts b/src/frontend/packages/core/src/core/extension/dynamic-extension-routes.ts index 2dc1d1d106..e27d1daa19 100644 --- a/src/frontend/packages/core/src/core/extension/dynamic-extension-routes.ts +++ b/src/frontend/packages/core/src/core/extension/dynamic-extension-routes.ts @@ -6,14 +6,14 @@ import { getRoutesFromExtensions, StratosRouteType } from './extension-service'; /** * This is used to dynamically add an extension's routes - since we can't do this - * if the extentsion's module is lazy-loaded. + * if the extension's module is lazy-loaded. * * This CanActive plugin typically is added to the route config to catch all unknown routes '**' - * When activated, it removes itself from the routing congif, so it only evern activates once. + * When activated, it removes itself from the routing config, so it only ever activates once. * * It checks if there are any new routes from extensions that need to be added and add them. * - * Lastly, it navigates to the saem route that it intercepted - if a new extension route + * Lastly, it navigates to the same route that it intercepted - if a new extension route * was added that now matches, it gets the route, otherwise the route goes up the chain * as it would have before. */ diff --git a/src/frontend/packages/core/src/core/extension/extension-service.ts b/src/frontend/packages/core/src/core/extension/extension-service.ts index 53f53c3599..7fcaa88c0c 100644 --- a/src/frontend/packages/core/src/core/extension/extension-service.ts +++ b/src/frontend/packages/core/src/core/extension/extension-service.ts @@ -1,9 +1,11 @@ -import { Injectable } from '@angular/core'; -import { Route, Router } from '@angular/router'; +import { Injectable, Type } from '@angular/core'; +import { ActivatedRoute, Route, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { AppState } from '../../../../store/src/app-state'; +import { IPageSideNavTab } from '../../features/dashboard/page-side-nav/page-side-nav.component'; +import { EntityServiceFactory } from '../entity-service-factory.service'; import { EndpointAuthTypeConfig, EndpointTypeExtensionConfig, ExtensionEntitySchema } from './extension-types'; export const extensionsActionRouteKey = 'extensionsActionsKey'; @@ -30,9 +32,15 @@ export enum StratosTabType { } export interface StratosTabMetadata { - type: StratosTabType; label: string; link: string; + icon: string; + iconFont?: string; + hidden?: (store: Store, esf: EntityServiceFactory, activatedRoute: ActivatedRoute) => Observable; +} + +export interface StratosTabMetadataConfig extends StratosTabMetadata { + type: StratosTabType; } // The different types of Action @@ -70,26 +78,31 @@ export interface StratosEndpointExtensionConfig { export type StratosRouteType = StratosTabType | StratosActionType; +export interface StratosExtensionRoutes { + path: string; + component: any; +} + // Stores the extension metadata as defined by the decorators const extensionMetadata = { loginComponent: null, - extensionRoutes: {}, - tabs: {}, - actions: {}, + extensionRoutes: {} as { [key: string]: StratosExtensionRoutes[] }, + tabs: {} as { [key: string]: IPageSideNavTab[] }, + actions: {} as { [key: string]: StratosActionMetadata[] }, endpointTypes: [], authTypes: [], entities: [] as ExtensionEntitySchema[] }; /** - * Decortator for a Tab extension + * Decorator for a Tab extension */ -export function StratosTab(props: StratosTabMetadata) { +export function StratosTab(props: StratosTabMetadataConfig) { return target => addExtensionTab(props.type, target, props); } /** - * Decortator for an Action extension + * Decorator for an Action extension */ export function StratosAction(props: StratosActionMetadata) { return target => addExtensionAction(props.type, target, props); @@ -116,7 +129,7 @@ export function StratosLoginComponent() { return target => extensionMetadata.loginComponent = target; } -function addExtensionTab(tab: StratosTabType, target: any, props: any) { +function addExtensionTab(tab: StratosTabType, target: any, props: StratosTabMetadataConfig) { if (!extensionMetadata.tabs[tab]) { extensionMetadata.tabs[tab] = []; } @@ -128,10 +141,12 @@ function addExtensionTab(tab: StratosTabType, target: any, props: any) { path: props.link, component: target }); - extensionMetadata.tabs[tab].push(props); + extensionMetadata.tabs[tab].push({ + ...props + }); } -function addExtensionAction(action: StratosActionType, target: any, props: any) { +function addExtensionAction(action: StratosActionType, target: any, props: StratosActionMetadata) { if (!extensionMetadata.actions[action]) { extensionMetadata.actions[action] = []; extensionMetadata.extensionRoutes[action] = []; @@ -208,11 +223,11 @@ export class ExtensionService { // Helpers to access Extension metadata (without using the injectable Extension Service) -export function getRoutesFromExtensions(routeType: StratosRouteType) { +export function getRoutesFromExtensions(routeType: StratosRouteType): StratosExtensionRoutes[] { return extensionMetadata.extensionRoutes[routeType] || []; } -export function getTabsFromExtensions(tabType: StratosTabType) { +export function getTabsFromExtensions(tabType: StratosTabType): IPageSideNavTab[] { return extensionMetadata.tabs[tabType] || []; } diff --git a/src/frontend/packages/core/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts b/src/frontend/packages/core/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts index 909e616161..05660c1fd8 100644 --- a/src/frontend/packages/core/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts +++ b/src/frontend/packages/core/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts @@ -94,13 +94,13 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { ); this.tabLinks = [ - { link: 'summary', label: 'Summary', matIcon: 'description' }, - { link: 'instances', label: 'Instances', matIcon: 'library_books' }, - { link: 'routes', label: 'Routes', matIconFont: 'stratos-icons', matIcon: 'network_route' }, - { link: 'log-stream', label: 'Log Stream', matIcon: 'featured_play_list' }, - { link: 'services', label: 'Services', matIconFont: 'stratos-icons', matIcon: 'service' }, - { link: 'variables', label: 'Variables', matIcon: 'list', hidden: appDoesNotHaveEnvVars$ }, - { link: 'events', label: 'Events', matIcon: 'watch_later' } + { link: 'summary', label: 'Summary', icon: 'description' }, + { link: 'instances', label: 'Instances', icon: 'library_books' }, + { link: 'routes', label: 'Routes', iconFont: 'stratos-icons', icon: 'network_route' }, + { link: 'log-stream', label: 'Log Stream', icon: 'featured_play_list' }, + { link: 'services', label: 'Services', iconFont: 'stratos-icons', icon: 'service' }, + { link: 'variables', label: 'Variables', icon: 'list', hidden$: appDoesNotHaveEnvVars$ }, + { link: 'events', label: 'Events', icon: 'watch_later' } ]; this.endpointsService.hasMetrics(applicationService.cfGuid).subscribe(hasMetrics => { @@ -110,14 +110,17 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { { link: 'metrics', label: 'Metrics', - matIcon: 'equalizer' + icon: 'equalizer' } ]; } }); // Add any tabs from extensions - this.tabLinks = this.tabLinks.concat(getTabsFromExtensions(StratosTabType.Application)); + const tabs = getTabsFromExtensions(StratosTabType.Application); + tabs.map((extensionTab) => { + this.tabLinks.push(extensionTab); + }); // Ensure Git SCM tab gets updated if the app is redeployed from a different SCM Type this.stratosProjectSub = this.applicationService.applicationStratProject$ @@ -133,11 +136,11 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { // Add tab or update existing tab const tab = this.tabLinks.find(t => t.link === 'gitscm'); if (!tab) { - this.tabLinks.push({ link: 'gitscm', label: scm.getLabel(), matIconFont: iconInfo.fontName, matIcon: iconInfo.iconName }); + this.tabLinks.push({ link: 'gitscm', label: scm.getLabel(), iconFont: iconInfo.fontName, icon: iconInfo.iconName }); } else { tab.label = scm.getLabel(); - tab.matIconFont = iconInfo.fontName; - tab.matIcon = iconInfo.iconName; + tab.iconFont = iconInfo.fontName; + tab.icon = iconInfo.iconName; } this.tabLinks = [...this.tabLinks]; } diff --git a/src/frontend/packages/core/src/features/applications/application/application-tabs-base/tabs/events-tab/events-tab.component.scss b/src/frontend/packages/core/src/features/applications/application/application-tabs-base/tabs/events-tab/events-tab.component.scss index b7248b408e..e69de29bb2 100644 --- a/src/frontend/packages/core/src/features/applications/application/application-tabs-base/tabs/events-tab/events-tab.component.scss +++ b/src/frontend/packages/core/src/features/applications/application/application-tabs-base/tabs/events-tab/events-tab.component.scss @@ -1,25 +0,0 @@ -[app-table-content] { - mat-header-cell, - mat-cell { - flex-grow: 1; - } - - .cdk-column-actor_name { - align-items: center; - display: flex; - flex-basis: 100px; - flex-direction: row; - } - - .cdk-column-type { - flex-basis: 100px; - } - - .cdk-column-detail { - flex-grow: 3; - - td { - padding-right: 5px; - } - } -} diff --git a/src/frontend/packages/core/src/features/cloud-foundry/cloud-foundry-tabs-base/cloud-foundry-tabs-base.component.ts b/src/frontend/packages/core/src/features/cloud-foundry/cloud-foundry-tabs-base/cloud-foundry-tabs-base.component.ts index ffa9b593f6..83dfa4dde8 100644 --- a/src/frontend/packages/core/src/features/cloud-foundry/cloud-foundry-tabs-base/cloud-foundry-tabs-base.component.ts +++ b/src/frontend/packages/core/src/features/cloud-foundry/cloud-foundry-tabs-base/cloud-foundry-tabs-base.component.ts @@ -69,31 +69,31 @@ export class CloudFoundryTabsBaseComponent implements OnInit { // Default tabs + add any tabs from extensions this.tabLinks = [ - { link: 'summary', label: 'Summary', matIcon: 'description' }, - { link: 'organizations', label: 'Organizations', matIcon: 'organization', matIconFont: 'stratos-icons' }, + { link: 'summary', label: 'Summary', icon: 'description' }, + { link: 'organizations', label: 'Organizations', icon: 'organization', iconFont: 'stratos-icons' }, { link: CloudFoundryTabsBaseComponent.cells, label: 'Cells', - matIcon: 'select_all', - hidden: cellsHidden$ + icon: 'select_all', + hidden$: cellsHidden$ }, - { link: 'routes', label: 'Routes', matIcon: 'network_route', matIconFont: 'stratos-icons', }, + { link: 'routes', label: 'Routes', icon: 'network_route', iconFont: 'stratos-icons', }, { link: CloudFoundryTabsBaseComponent.users, label: 'Users', - hidden: usersHidden$, - matIcon: 'people' + hidden$: usersHidden$, + icon: 'people' }, { link: CloudFoundryTabsBaseComponent.firehose, label: 'Firehose', - hidden: firehoseHidden$, - matIcon: 'featured_play_list' + hidden$: firehoseHidden$, + icon: 'featured_play_list' }, - { link: 'feature-flags', label: 'Feature Flags', matIcon: 'flag' }, - { link: 'build-packs', label: 'Build Packs', matIcon: 'build' }, - { link: 'stacks', label: 'Stacks', matIcon: 'code' }, - { link: 'security-groups', label: 'Security Groups', matIcon: 'security' }, + { link: 'feature-flags', label: 'Feature Flags', icon: 'flag' }, + { link: 'build-packs', label: 'Build Packs', icon: 'build' }, + { link: 'stacks', label: 'Stacks', icon: 'code' }, + { link: 'security-groups', label: 'Security Groups', icon: 'security' }, ...getTabsFromExtensions(StratosTabType.CloudFoundry) ]; } diff --git a/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts b/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts index 62450af26e..509440c673 100644 --- a/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts +++ b/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts @@ -26,18 +26,18 @@ export class CloudFoundryCellBaseComponent { { link: 'summary', label: 'Summary', - matIcon: 'description' + icon: 'description' }, { link: 'charts', label: 'Metrics', - matIcon: 'equalizer' + icon: 'equalizer' }, { link: CloudFoundryCellBaseComponent.AppsLinks, label: 'App Instances', - matIcon: 'application_instance', - matIconFont: 'stratos-icons' + icon: 'application_instance', + iconFont: 'stratos-icons' }, ]; @@ -72,7 +72,7 @@ export class CloudFoundryCellBaseComponent { first() ); - this.tabLinks.find(link => link.link === CloudFoundryCellBaseComponent.AppsLinks).hidden = + this.tabLinks.find(link => link.link === CloudFoundryCellBaseComponent.AppsLinks).hidden$ = cfEndpointService.currentUser$.pipe( map(user => !user.admin) ); diff --git a/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-base/cloud-foundry-organization-base.component.ts b/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-base/cloud-foundry-organization-base.component.ts index d9588ba993..2e66141283 100644 --- a/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-base/cloud-foundry-organization-base.component.ts +++ b/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-base/cloud-foundry-organization-base.component.ts @@ -38,22 +38,22 @@ export class CloudFoundryOrganizationBaseComponent { { link: 'summary', label: 'Summary', - matIcon: 'description' + icon: 'description' }, { link: 'spaces', label: 'Spaces', - matIcon: 'language' + icon: 'language' }, { link: 'users', label: 'Users', - matIcon: 'people' + icon: 'people' }, { link: 'quota', label: 'Quota', - matIcon: 'data_usage' + icon: 'data_usage' } ]; public breadcrumbs$: Observable; diff --git a/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts b/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts index ba95939bb4..f6d56c1727 100644 --- a/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts +++ b/src/frontend/packages/core/src/features/cloud-foundry/tabs/cloud-foundry-organizations/cloud-foundry-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts @@ -44,35 +44,35 @@ export class CloudFoundrySpaceBaseComponent implements OnDestroy { { link: 'summary', label: 'Summary', - matIcon: 'description' + icon: 'description' }, { link: 'apps', label: 'Applications', - matIcon: 'apps' + icon: 'apps' }, { link: 'service-instances', label: 'Services', - matIconFont: 'stratos-icons', - matIcon: 'service' + iconFont: 'stratos-icons', + icon: 'service' }, { link: 'user-service-instances', label: 'User Services', - matIconFont: 'stratos-icons', - matIcon: 'service_square' + iconFont: 'stratos-icons', + icon: 'service_square' }, { link: 'routes', label: 'Routes', - matIconFont: 'stratos-icons', - matIcon: 'network_route' + iconFont: 'stratos-icons', + icon: 'network_route' }, { link: 'users', label: 'Users', - matIcon: 'people' + icon: 'people' } ]; @@ -138,8 +138,8 @@ export class CloudFoundrySpaceBaseComponent implements OnDestroy { this.tabLinks.push({ link: 'space-quota', label: 'Quota', - matIcon: 'data_usage', - hidden: of(!space.entity.entity.space_quota_definition) + icon: 'data_usage', + hidden$: of(!space.entity.entity.space_quota_definition) }); this.tabLinks = this.tabLinks.concat(getTabsFromExtensions(StratosTabType.CloudFoundrySpace)); }), diff --git a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.html b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.html index b16004c4e1..09f01fda05 100644 --- a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.html +++ b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.html @@ -9,11 +9,11 @@
  • - - {{ tab.matIcon }} + {{ tab.icon }} - + {{ tab.label }}
  • diff --git a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.spec.ts b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.spec.ts index 70ff27cf1e..8ada83ed4d 100644 --- a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.spec.ts +++ b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.spec.ts @@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TabNavService } from '../../../../tab-nav.service'; import { BaseTestModulesNoShared } from '../../../../test-framework/cloud-foundry-endpoint-service.helper'; +import { EntityMonitorFactory } from '../../../shared/monitors/entity-monitor.factory.service'; import { PageSideNavComponent } from './page-side-nav.component'; describe('PageSideNavComponent', () => { @@ -12,7 +13,7 @@ describe('PageSideNavComponent', () => { TestBed.configureTestingModule({ imports: [BaseTestModulesNoShared], declarations: [PageSideNavComponent], - providers: [TabNavService] + providers: [TabNavService, EntityMonitorFactory] }) .compileComponents(); })); diff --git a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts index f87c89903c..66c4e1adac 100644 --- a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts +++ b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts @@ -1,18 +1,17 @@ -import { Component, OnInit, Input } from '@angular/core'; -import { Observable } from 'rxjs'; -import { IBreadcrumb } from '../../../shared/components/breadcrumbs/breadcrumbs.types'; -import { TabNavService } from '../../../../tab-nav.service'; +import { Component, Input, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; +import { Observable, of } from 'rxjs'; + import { AppState } from '../../../../../store/src/app-state'; import { selectIsMobile } from '../../../../../store/src/selectors/dashboard.selectors'; +import { TabNavService } from '../../../../tab-nav.service'; +import { EntityServiceFactory } from '../../../core/entity-service-factory.service'; +import { StratosTabMetadata } from '../../../core/extension/extension-service'; +import { IBreadcrumb } from '../../../shared/components/breadcrumbs/breadcrumbs.types'; -export interface IPageSideNavTab { - key?: string; - label: string; - matIcon?: string; - matIconFont?: string; - link: string; - hidden?: Observable; +export interface IPageSideNavTab extends StratosTabMetadata { + hidden$?: Observable; } @Component({ @@ -22,19 +21,33 @@ export interface IPageSideNavTab { }) export class PageSideNavComponent implements OnInit { - @Input() - public tabs: IPageSideNavTab[]; + pTabs: IPageSideNavTab[]; + @Input() set tabs(tabs: IPageSideNavTab[]) { + if (!tabs || (this.pTabs && tabs.length === this.pTabs.length)) { + return; + } + this.pTabs = tabs.map(tab => ({ + ...tab, + hidden$: tab.hidden$ || (tab.hidden ? tab.hidden(this.store, this.esf, this.activatedRoute) : of(false)) + })); + } + get tabs(): IPageSideNavTab[] { + return this.pTabs; + } @Input() public header: string; public activeTab$: Observable; public breadcrumbs$: Observable; - public isMobile$ = this.store.select(selectIsMobile); - + public isMobile$: Observable; constructor( - public tabNavService: TabNavService, - private store: Store> - ) { } + private store: Store, + private esf: EntityServiceFactory, + private activatedRoute: ActivatedRoute, + public tabNavService: TabNavService + ) { + this.isMobile$ = this.store.select(selectIsMobile); + } ngOnInit() { this.activeTab$ = this.tabNavService.getCurrentTabHeaderObservable(); diff --git a/src/frontend/packages/core/src/features/service-catalog/service-tabs-base/service-tabs-base.component.ts b/src/frontend/packages/core/src/features/service-catalog/service-tabs-base/service-tabs-base.component.ts index 7ded62f867..f2fdb84368 100644 --- a/src/frontend/packages/core/src/features/service-catalog/service-tabs-base/service-tabs-base.component.ts +++ b/src/frontend/packages/core/src/features/service-catalog/service-tabs-base/service-tabs-base.component.ts @@ -3,11 +3,11 @@ import { Store } from '@ngrx/store'; import { Observable, Subscription } from 'rxjs'; import { map, publishReplay, refCount } from 'rxjs/operators'; -import { IHeaderBreadcrumb } from '../../../shared/components/page-header/page-header.types'; -import { ServicesService } from '../services.service'; -import { CurrentUserPermissions } from '../../../core/current-user-permissions.config'; import { AppState } from '../../../../../store/src/app-state'; +import { CurrentUserPermissions } from '../../../core/current-user-permissions.config'; +import { IHeaderBreadcrumb } from '../../../shared/components/page-header/page-header.types'; import { IPageSideNavTab } from '../../dashboard/page-side-nav/page-side-nav.component'; +import { ServicesService } from '../services.service'; @Component({ selector: 'app-service-tabs-base', @@ -24,19 +24,19 @@ export class ServiceTabsBaseComponent { { link: 'summary', label: 'Summary', - matIcon: 'description' + icon: 'description' }, { link: 'instances', label: 'Instances', - matIcon: 'service_instance', - matIconFont: 'stratos-icons' + icon: 'service_instance', + iconFont: 'stratos-icons' }, { link: 'plans', label: 'Plans', - matIcon: 'service_plan', - matIconFont: 'stratos-icons' + icon: 'service_plan', + iconFont: 'stratos-icons' } ]; diff --git a/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.html b/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.html index ca927f05aa..f26820ae69 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.html +++ b/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.html @@ -2,10 +2,8 @@ Recently updated applications - +
    diff --git a/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.scss b/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.scss index 051908a95a..e6f70f9ab0 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.scss +++ b/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.scss @@ -20,28 +20,7 @@ margin-bottom: 0; } - .refresh-icon { - animation: spin .4s infinite linear; - animation-play-state: paused; - transform: rotate(0deg); - transition: transform 1s linear; - } - - .refreshing { - animation-play-state: running; - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(359deg); - } - } - - button { + app-polling-indicator { margin-right: -10px; margin-top: -10px; } diff --git a/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts b/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts index 2000b04db6..c6531ceb41 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/cards/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts @@ -14,6 +14,7 @@ import { ActiveRouteCfOrgSpace } from '../../../../features/cloud-foundry/cf-pag import { EntityMonitorFactory } from '../../../monitors/entity-monitor.factory.service'; import { CfUserService } from '../../../data-services/cf-user.service'; import { PaginationMonitorFactory } from '../../../monitors/pagination-monitor.factory'; +import { PollingIndicatorComponent } from '../../polling-indicator/polling-indicator.component'; describe('CardCfRecentAppsComponent', () => { let component: CardCfRecentAppsComponent; @@ -25,6 +26,7 @@ describe('CardCfRecentAppsComponent', () => { CardCfRecentAppsComponent, ApplicationStateIconComponent, CompactAppCardComponent, + PollingIndicatorComponent, ApplicationStateIconPipe ], imports: [...BaseTestModulesNoShared], diff --git a/src/frontend/packages/core/src/shared/components/favorites-entity-list/favorites-entity-list.component.ts b/src/frontend/packages/core/src/shared/components/favorites-entity-list/favorites-entity-list.component.ts index dceb33f5db..60afbb6617 100644 --- a/src/frontend/packages/core/src/shared/components/favorites-entity-list/favorites-entity-list.component.ts +++ b/src/frontend/packages/core/src/shared/components/favorites-entity-list/favorites-entity-list.component.ts @@ -109,6 +109,7 @@ export class FavoritesEntityListComponent implements OnInit { return entities.filter(entity => entity.favorite.entityType === type); }) ); + this.searchedEntities$ = combineLatest( typesEntities$, searchValue$.pipe(startWith('')), diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts index 0b47f12bd7..8ac333aa4e 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts @@ -91,14 +91,18 @@ export interface IListDataSourceConfig { /** * Optional list configuration */ - listConfig?: IListConfig; + listConfig: IListConfig; /** * A function that will be called when the list is destroyed. */ destroy?: () => void; + /** + * A function that will be called instead of the default refresh + */ refresh?: () => void; + /** * A function which fetches an observable containing a specific row's state * diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts index ff192a68dc..eda6983908 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts @@ -26,7 +26,7 @@ import { import { ListFilter, ListSort } from '../../../../../../store/src/actions/list.actions'; import { MetricsAction } from '../../../../../../store/src/actions/metrics.actions'; -import { SetResultCount } from '../../../../../../store/src/actions/pagination.actions'; +import { SetParams, SetResultCount } from '../../../../../../store/src/actions/pagination.actions'; import { AppState } from '../../../../../../store/src/app-state'; import { entityFactory, EntitySchema } from '../../../../../../store/src/helpers/entity-factory'; import { getPaginationObservables } from '../../../../../../store/src/reducers/pagination-reducer/pagination-reducer.helper'; @@ -124,7 +124,7 @@ export abstract class ListDataSource extends DataSource implements private pageSubscription: Subscription; private transformedEntitiesSubscription: Subscription; private seedSyncSub: Subscription; - private metricsAction: MetricsAction; + protected metricsAction: MetricsAction; public entitySelectConfig: EntitySelectConfig; public refresh: () => void; @@ -440,7 +440,19 @@ export abstract class ListDataSource extends DataSource implements public updateMetricsAction(newAction: MetricsAction) { this.metricsAction = newAction; - this.store.dispatch(newAction); + + if (this.isLocal) { + this.store.dispatch(newAction); + } else { + this.pagination$.pipe( + first() + ).subscribe(pag => { + this.store.dispatch(new SetParams(newAction.entityKey, this.paginationKey, { + ...pag.params, + metricConfig: newAction.query + }, false, true)); + }); + } } private createSortObservable(): Observable { @@ -450,9 +462,7 @@ export abstract class ListDataSource extends DataSource implements field: pag.params['order-direction-field'] })), filter(x => !!x), - distinctUntilChanged((x, y) => { - return x.direction === y.direction && x.field === y.field; - }), + distinctUntilChanged((x, y) => x.direction === y.direction && x.field === y.field), tag('list-sort') ); } diff --git a/src/frontend/packages/core/src/shared/components/list/list-cards/card/card.component.ts b/src/frontend/packages/core/src/shared/components/list/list-cards/card/card.component.ts index 2724511dab..64dd9a92e0 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-cards/card/card.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-cards/card/card.component.ts @@ -24,7 +24,6 @@ import { import { CardCell } from '../../list.types'; import { CardDynamicComponent, CardMultiActionComponents } from '../card.component.types'; - export const listCards = [ CardAppComponent, CfOrgCardComponent, diff --git a/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.html b/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.html index a288c4b4d3..1ad1028138 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.html +++ b/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.html @@ -1,5 +1,5 @@
    - + diff --git a/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.ts b/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.ts index ec7c9954a7..c3e5c8193e 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-cards/cards.component.ts @@ -18,16 +18,20 @@ export class CardsComponent { get component() { return this.pComponent; } set component(cardCell: CardTypes) { this.pComponent = cardCell; + /* tslint:disable-next-line */ + this.columns = cardCell['columns']; } - public multiActionTrackBy(index: number, item: any | MultiActionListEntity) { - if (!this.dataSource) { - return null; - } - if (this.isMultiActionItem(item)) { - return this.dataSource.trackBy(index, item.entity); - } - return this.dataSource.trackBy(index, item); + public multiActionTrackBy() { + return (index: number, item: any | MultiActionListEntity) => { + if (!this.dataSource) { + return null; + } + if (this.isMultiActionItem(item)) { + return this.dataSource.trackBy(index, item.entity); + } + return this.dataSource.trackBy(index, item); + }; } public isMultiActionItem(component: any | MultiActionListEntity) { diff --git a/src/frontend/packages/core/src/shared/components/list/list-cards/cards.components.theme.scss b/src/frontend/packages/core/src/shared/components/list/list-cards/cards.components.theme.scss index 197f88dde1..83cca6ab3a 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-cards/cards.components.theme.scss +++ b/src/frontend/packages/core/src/shared/components/list/list-cards/cards.components.theme.scss @@ -6,6 +6,11 @@ margin-bottom: $space; width: 100%; } + &__columns-1 { + app-card { + width: 100%; + } + } &__columns-2 { app-card { @include breakpoint(tablet) { diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-config.service.ts index d5a20ddd81..a4fc8ce123 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-config.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { AppState } from '../../../../../../../store/src/app-state'; +import { APIResource } from '../../../../../../../store/src/types/api.types'; import { ApplicationService } from '../../../../../features/applications/application.service'; import { ITableColumn } from '../../list-table/table.types'; import { IListConfig, ListConfig, ListViewTypes } from '../../list.component.types'; @@ -9,14 +11,12 @@ import { TableCellEventActionComponent } from './table-cell-event-action/table-c import { TableCellEventDetailComponent } from './table-cell-event-detail/table-cell-event-detail.component'; import { TableCellEventTimestampComponent } from './table-cell-event-timestamp/table-cell-event-timestamp.component'; import { TableCellEventTypeComponent } from './table-cell-event-type/table-cell-event-type.component'; -import { EntityInfo } from '../../../../../../../store/src/types/api.types'; -import { AppState } from '../../../../../../../store/src/app-state'; @Injectable() -export class CfAppEventsConfigService extends ListConfig implements IListConfig { +export class CfAppEventsConfigService extends ListConfig implements IListConfig { eventSource: CfAppEventsDataSource; - columns: Array> = [ + columns: Array> = [ { columnId: 'timestamp', headerCell: () => 'Timestamp', cellComponent: TableCellEventTimestampComponent, sort: true, cellFlex: '3' }, @@ -42,6 +42,7 @@ export class CfAppEventsConfigService extends ListConfig implements this.store, this.appService.cfGuid, this.appService.appGuid, + this ); } diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-data-source.ts b/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-data-source.ts index e8b39c951c..b92d6539fa 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/app-event/cf-app-events-data-source.ts @@ -4,11 +4,13 @@ import { GetAllAppEvents } from '../../../../../../../store/src/actions/app-even import { AddParams, RemoveParams } from '../../../../../../../store/src/actions/pagination.actions'; import { AppState } from '../../../../../../../store/src/app-state'; import { appEventSchemaKey, entityFactory } from '../../../../../../../store/src/helpers/entity-factory'; -import { EntityInfo } from '../../../../../../../store/src/types/api.types'; +import { APIResource } from '../../../../../../../store/src/types/api.types'; import { PaginationEntityState, QParam } from '../../../../../../../store/src/types/pagination.types'; +import { getRowMetadata } from '../../../../../features/cloud-foundry/cf.helpers'; import { ListDataSource } from '../../data-sources-controllers/list-data-source'; +import { IListConfig } from '../../list.component.types'; -export class CfAppEventsDataSource extends ListDataSource { +export class CfAppEventsDataSource extends ListDataSource { public getFilterFromParams(pag: PaginationEntityState) { const qParams = pag.params.q; @@ -35,6 +37,7 @@ export class CfAppEventsDataSource extends ListDataSource { store: Store, cfGuid: string, appGuid: string, + listConfig: IListConfig ) { const paginationKey = `app-events:${cfGuid}${appGuid}`; const action = new GetAllAppEvents(paginationKey, appGuid, cfGuid); @@ -44,10 +47,9 @@ export class CfAppEventsDataSource extends ListDataSource { store, action, schema: entityFactory(appEventSchemaKey), - getRowUniqueId: (object: EntityInfo) => { - return object.entity.metadata ? object.entity.metadata.guid : null; - }, + getRowUniqueId: getRowMetadata, paginationKey, + listConfig } ); diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts b/src/frontend/packages/core/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts index 747de07e5a..a2156d434d 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts @@ -11,7 +11,7 @@ export class BaseCfListConfig implements IListConfig { defaultView = 'cards' as ListView; cardComponent: CardTypes; enableTextFilter = false; - showMetricsRange = false; + showCustomTime = false; getColumns = () => []; getGlobalActions = () => []; getMultiActions = () => []; diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/cf-cell-health/cf-cell-health-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/cf-cell-health/cf-cell-health-list-config.service.ts index bf58bf31fa..df3c6d6fe0 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/cf-cell-health/cf-cell-health-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/cf-cell-health/cf-cell-health-list-config.service.ts @@ -1,22 +1,26 @@ import { DatePipe } from '@angular/common'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { CfCellHealthDataSource, CfCellHealthEntry, CfCellHealthState } from './cf-cell-health-source'; -import { BaseCfListConfig } from '../base-cf/base-cf-list-config'; + import { ListView } from '../../../../../../../store/src/actions/list.actions'; -import { ListViewTypes } from '../../list.component.types'; import { - TableCellBooleanIndicatorComponentConfig, - TableCellBooleanIndicatorComponent -} from '../../list-table/table-cell-boolean-indicator/table-cell-boolean-indicator.component'; -import { BooleanIndicatorType } from '../../../boolean-indicator/boolean-indicator.component'; + FetchCFCellMetricsPaginatedAction, + MetricQueryConfig, +} from '../../../../../../../store/src/actions/metrics.actions'; import { AppState } from '../../../../../../../store/src/app-state'; import { - CloudFoundryCellService + CloudFoundryCellService, } from '../../../../../features/cloud-foundry/tabs/cloud-foundry-cells/cloud-foundry-cell/cloud-foundry-cell.service'; -import { FetchCFCellMetricsPaginatedAction, MetricQueryConfig } from '../../../../../../../store/src/actions/metrics.actions'; import { MetricQueryType } from '../../../../services/metrics-range-selector.types'; +import { BooleanIndicatorType } from '../../../boolean-indicator/boolean-indicator.component'; +import { + TableCellBooleanIndicatorComponent, + TableCellBooleanIndicatorComponentConfig, +} from '../../list-table/table-cell-boolean-indicator/table-cell-boolean-indicator.component'; import { ITableColumn } from '../../list-table/table.types'; +import { ListViewTypes } from '../../list.component.types'; +import { BaseCfListConfig } from '../base-cf/base-cf-list-config'; +import { CfCellHealthDataSource, CfCellHealthEntry, CfCellHealthState } from './cf-cell-health-source'; @Injectable() export class CfCellHealthListConfigService extends BaseCfListConfig { @@ -42,7 +46,7 @@ export class CfCellHealthListConfigService extends BaseCfListConfig> { viewType = ListViewTypes.TABLE_ONLY; pageSizeOptions = defaultPaginationPageSizeOptionsTable; - dataSource: ListDataSource; + dataSource: ListDataSource>; defaultView = 'table' as ListView; text = { title: null, diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.html b/src/frontend/packages/core/src/shared/components/list/list.component.html index edd098279a..01ffbfc939 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.html +++ b/src/frontend/packages/core/src/shared/components/list/list.component.html @@ -14,9 +14,12 @@ config.text?.title }}
    + *ngIf="config.showCustomTime && !(isAddingOrSelecting$ | async)"> + (metricsAction)="config.getDataSource().updateMetricsAction($event)" [times]="config.customTimeWindows" + [selectedTimeValue]="config.customTimeInitialValue" [pollInterval]="config.customTimePollingInterval" + [validate]="config.customTimeValidation"> +
    diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.types.ts b/src/frontend/packages/core/src/shared/components/list/list.component.types.ts index 5278a347de..8c0775fb64 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.types.ts +++ b/src/frontend/packages/core/src/shared/components/list/list.component.types.ts @@ -1,3 +1,4 @@ +import * as moment from 'moment'; import { BehaviorSubject, combineLatest, Observable, of as observableOf } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; @@ -6,6 +7,7 @@ import { ActionState } from '../../../../../store/src/reducers/api-request-reduc import { defaultClientPaginationPageSize, } from '../../../../../store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination'; +import { ITimeRange } from '../../services/metrics-range-selector.types'; import { ListDataSource } from './data-sources-controllers/list-data-source'; import { IListDataSource } from './data-sources-controllers/list-data-source-types'; import { CardTypes } from './list-cards/card/card.component'; @@ -90,7 +92,23 @@ export interface IListConfig { /** * For metrics based data show a metrics range selector */ - showMetricsRange?: boolean; + showCustomTime?: boolean; + /** + * Custom time window to show in metrics range selector + */ + customTimeWindows?: ITimeRange[]; + /** + * Custom time window validation for metrics range selector + */ + customTimeValidation?: (start: moment.Moment, end: moment.Moment) => string; + /** + * Custom time polling interval. Falsy for disabled. + */ + customTimePollingInterval?: number; + /** + * When enabled set the initial value + */ + customTimeInitialValue?: string; } export interface IListMultiFilterConfig { diff --git a/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.html b/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.html index 7e81408eba..84f6d5a596 100644 --- a/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.html +++ b/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.html @@ -6,4 +6,4 @@
    - + \ No newline at end of file diff --git a/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.ts b/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.ts index b46ed767c5..2f45c98d6d 100644 --- a/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.ts +++ b/src/frontend/packages/core/src/shared/components/loading-page/loading-page.component.ts @@ -2,7 +2,7 @@ import { animate, style, transition, trigger } from '@angular/animations'; import { Component, Input, OnInit } from '@angular/core'; import { schema } from 'normalizr'; import { combineLatest, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, filter, first, map, startWith } from 'rxjs/operators'; +import { filter, first, map, startWith } from 'rxjs/operators'; import { EntityMonitor } from '../../monitors/entity-monitor'; import { EntityMonitorFactory } from '../../monitors/entity-monitor.factory.service'; diff --git a/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.html b/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.html index e655b5cac5..c1243f8f80 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.html +++ b/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.html @@ -1,11 +1,14 @@
    -

    Choose time window

    - +
    -
    @@ -19,8 +22,10 @@

    Choose time window

    -
    -
    +
    +
    {{ rangeSelectorManager.committedStartEnd[0] | amDateFormat:'Do MMM YYYY, HH:mm' }} @@ -32,6 +37,7 @@

    Choose time window

    No dates selected - Change + Change
    \ No newline at end of file diff --git a/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts b/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts index 118c13d341..584eccaf5b 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts @@ -1,13 +1,14 @@ import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import * as moment from 'moment'; import { Subscription } from 'rxjs'; +import { MetricsAction } from '../../../../../store/src/actions/metrics.actions'; +import { entityFactory, metricSchemaKey } from '../../../../../store/src/helpers/entity-factory'; +import { IMetrics } from '../../../../../store/src/types/base-metric.types'; import { EntityMonitor } from '../../monitors/entity-monitor'; import { EntityMonitorFactory } from '../../monitors/entity-monitor.factory.service'; import { MetricsRangeSelectorManagerService } from '../../services/metrics-range-selector-manager.service'; -import { MetricQueryType } from '../../services/metrics-range-selector.types'; -import { IMetrics } from '../../../../../store/src/types/base-metric.types'; -import { MetricsAction } from '../../../../../store/src/actions/metrics.actions'; -import { metricSchemaKey, entityFactory } from '../../../../../store/src/helpers/entity-factory'; +import { ITimeRange, MetricQueryType } from '../../services/metrics-range-selector.types'; @Component({ selector: 'app-metrics-range-selector', @@ -59,11 +60,32 @@ export class MetricsRangeSelectorComponent implements OnDestroy { ); this.rangeSelectorManager.init(this.metricsMonitor, action); } - get baseAction() { return this.baseActionValue; } + @Input() + set times(customTimes: ITimeRange[]) { + if (customTimes && customTimes.length > 0) { + this.rangeSelectorManager.times = customTimes; + this.rangeSelectorManager.metricRangeService.times = customTimes; + } + } + + @Input() + set selectedTimeValue(timeValue: string) { + this.rangeSelectorManager.metricRangeService.defaultTimeValue = timeValue; + } + + @Input() + set pollInterval(interval: number) { + if (interval) { + this.rangeSelectorManager.pollInterval = interval; + } + } + + @Input() + public validate: (start: moment.Moment, end: moment.Moment) => string; set showOverlay(show: boolean) { this.showOverlayValue = show; diff --git a/src/frontend/packages/core/src/shared/components/simple-usage-chart/simple-usage-chart.component.theme.scss b/src/frontend/packages/core/src/shared/components/simple-usage-chart/simple-usage-chart.component.theme.scss index 508517789e..d565ac8dab 100644 --- a/src/frontend/packages/core/src/shared/components/simple-usage-chart/simple-usage-chart.component.theme.scss +++ b/src/frontend/packages/core/src/shared/components/simple-usage-chart/simple-usage-chart.component.theme.scss @@ -12,7 +12,7 @@ .simple-usage-graph-color { @each $color in $colors { &--#{nth($color, 1)} { - background-color: #{nth($color, 2)} + background-color: #{nth($color, 2)}; } &--#{nth($color, 1)}-background { background-color: transparentize(nth($color, 2), .8); diff --git a/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.html b/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.html index e1df11c40b..f2e5cba813 100644 --- a/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.html +++ b/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.html @@ -9,6 +9,6 @@
    -
    - Start date must be before end date. +
    + {{ validMessage }}
    \ No newline at end of file diff --git a/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.scss b/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.scss index 42b14c9b33..8d9ff8f5f7 100644 --- a/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.scss +++ b/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.scss @@ -14,5 +14,5 @@ .invalid-message { font-size: 12px; - margin-top: -15px; + min-height: 16px; } diff --git a/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.ts b/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.ts index 61af3a2852..310fb39648 100644 --- a/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.ts +++ b/src/frontend/packages/core/src/shared/components/start-end-date/start-end-date.component.ts @@ -7,13 +7,6 @@ import * as moment from 'moment'; styleUrls: ['./start-end-date.component.scss'] }) export class StartEndDateComponent { - @Output() - public endChange = new EventEmitter(); - @Output() - public startChange = new EventEmitter(); - - @Output() - public isValid = new EventEmitter(); get valid() { return this.validValue; @@ -24,23 +17,6 @@ export class StartEndDateComponent { this.isValid.emit(this.validValue); } - public validValue = true; - - private startValue: moment.Moment; - private endValue: moment.Moment; - - - private isDifferentDate(oldDate: moment.Moment, newDate: moment.Moment) { - return !oldDate || !newDate || !oldDate.isSame(newDate); - } - - private isStartEndValid(start: moment.Moment, end: moment.Moment) { - if (!end || !start) { - return true; - } - return start.isBefore(end); - } - @Input() set start(start: moment.Moment) { this.valid = true; @@ -49,14 +25,12 @@ export class StartEndDateComponent { return; } if (start.isValid()) { - if (!this.isStartEndValid(start, this.end)) { + const clone = moment(start); + this.startValue = clone; + if (!this.pValidate(start, this.end)) { this.valid = false; - return; - } - if (this.isDifferentDate(this.startValue, start)) { - const clone = moment(start); - this.startValue = clone; - this.startChange.emit(clone); + } else { + this.emitChanges(); } } } @@ -73,14 +47,12 @@ export class StartEndDateComponent { return; } if (end && end.isValid()) { - if (!this.isStartEndValid(this.start, end)) { + const clone = moment(end); + this.endValue = clone; + if (!this.pValidate(this.start, end)) { this.valid = false; - return; - } - if (this.isDifferentDate(this.endValue, end)) { - const clone = moment(end); - this.endValue = clone; - this.endChange.emit(clone); + } else { + this.emitChanges(); } } } @@ -88,4 +60,49 @@ export class StartEndDateComponent { get end() { return this.endValue; } + @Output() + public endChange = new EventEmitter(); + @Output() + public startChange = new EventEmitter(); + + @Output() + public isValid = new EventEmitter(); + + public validValue = true; + public validMessage: string; + + private startValue: moment.Moment; + private endValue: moment.Moment; + + private lastValidStartValue: moment.Moment; + private lastValidEndValue: moment.Moment; + + private emitChanges() { + if (this.isDifferentDate(this.lastValidStartValue, this.startValue)) { + this.lastValidStartValue = this.startValue; + this.startChange.emit(this.startValue); + } + if (this.isDifferentDate(this.lastValidEndValue, this.endValue)) { + this.lastValidEndValue = this.endValue; + this.endChange.emit(this.endValue); + } + } + + @Input() + public validate: (start: moment.Moment, end: moment.Moment) => string = (start: moment.Moment, end: moment.Moment): string => { + if (!end || !start) { + return null; + } + return end.isBefore(start) ? 'Start date must be before end date.' : null; + } + + private pValidate(start: moment.Moment, end: moment.Moment): boolean { + this.validMessage = this.validate(start, end); + return !this.validMessage; + } + + + private isDifferentDate(oldDate: moment.Moment, newDate: moment.Moment) { + return !oldDate || !newDate || !oldDate.isSame(newDate); + } } diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.html b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.html index ba82ed8443..3638baaa8f 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.html +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.html @@ -27,7 +27,7 @@
    + [disabled]="!canGoto(currentIndex - 1) || steps[currentIndex].disablePrevious">Previous