├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .githooks └── pre-push ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── angular.json ├── db.js ├── dist └── qan-app │ ├── 3rdpartylicenses.txt │ ├── assets │ ├── 50747258.png │ ├── Percona_XtraDB_Cluster.png │ ├── background.png │ ├── database.png │ ├── footer-logo.png │ ├── logo.png │ ├── mysql_64_black.png │ ├── percona-server-black-50.png │ ├── pmm-logo.svg │ └── sm-logo.png │ ├── favicon.ico │ ├── fontawesome-webfont.674f50d287a8c48dc19b.eot │ ├── fontawesome-webfont.912ec66d7572ff821749.svg │ ├── fontawesome-webfont.af7ae505a9eed503f8b8.woff2 │ ├── fontawesome-webfont.b06871f281fee6b241d6.ttf │ ├── fontawesome-webfont.fee66e712a8a08eef580.woff │ ├── footer-logo.f5f92e041aab6e0a0731.png │ ├── index.html │ ├── main.ee6df98d405bc48974d9.js │ ├── pmm-logo.774290ea45f094ac7586.svg │ ├── polyfills-es5.e492bfb9ad643cb45862.js │ ├── polyfills.9f7a0b0d8e877fe82e43.js │ ├── runtime.458556a34b891ea32398.js │ └── styles.83d91574d2ae0db906c8.css ├── e2e ├── app.e2e-spec.ts ├── app.po.ts └── tsconfig.e2e.json ├── jsons ├── 1.json └── qan-api │ └── instances.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── protractor-ci.conf.js ├── protractor.conf.js ├── proxy.conf.e2e.json ├── proxy.conf.json ├── routes.json ├── src ├── app │ ├── add-amazon-rds │ │ ├── add-amazon-rds.component.html │ │ ├── add-amazon-rds.component.scss │ │ ├── add-amazon-rds.component.spec.ts │ │ ├── add-amazon-rds.component.ts │ │ ├── add-amazon-rds.service.spec.ts │ │ └── add-amazon-rds.service.ts │ ├── add-instance │ │ ├── add-instance.component.html │ │ ├── add-instance.component.scss │ │ ├── add-instance.component.spec.ts │ │ └── add-instance.component.ts │ ├── add-remote-instances │ │ ├── add-remote-instance.component.html │ │ ├── add-remote-instance.component.scss │ │ ├── add-remote-instance.component.ts │ │ └── add-remote-instance.service.ts │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── core │ │ ├── core.component.ts │ │ ├── core.module.ts │ │ ├── instance.service.spec.ts │ │ ├── instance.service.ts │ │ ├── json-tree │ │ │ ├── json-tree.component.html │ │ │ ├── json-tree.component.scss │ │ │ └── json-tree.component.ts │ │ ├── nav │ │ │ ├── nav.component.html │ │ │ ├── nav.component.scss │ │ │ ├── nav.component.spec.ts │ │ │ └── nav.component.ts │ │ ├── page-not-found │ │ │ ├── page-not-found.component.html │ │ │ ├── page-not-found.component.scss │ │ │ ├── page-not-found.component.spec.ts │ │ │ └── page-not-found.component.ts │ │ └── qan-error.handler.ts │ ├── environment.ts │ ├── mongo-query-details │ │ ├── mongo-query-details.component.html │ │ ├── mongo-query-details.component.scss │ │ ├── mongo-query-details.component.spec.ts │ │ ├── mongo-query-details.component.ts │ │ ├── mongo-query-details.service.spec.ts │ │ └── mongo-query-details.service.ts │ ├── mysql-query-details │ │ ├── mysql-query-details.component.html │ │ ├── mysql-query-details.component.scss │ │ ├── mysql-query-details.component.spec.ts │ │ ├── mysql-query-details.component.ts │ │ ├── mysql-query-details.service.spec.ts │ │ └── mysql-query-details.service.ts │ ├── query-profile │ │ ├── query-profile.component.html │ │ ├── query-profile.component.scss │ │ ├── query-profile.component.spec.ts │ │ ├── query-profile.component.ts │ │ ├── query-profile.service.spec.ts │ │ └── query-profile.service.ts │ ├── remote-instances-list │ │ ├── remote-instances-list.component.html │ │ ├── remote-instances-list.component.scss │ │ ├── remote-instances-list.component.spec.ts │ │ ├── remote-instances-list.component.ts │ │ ├── remote-instances-list.service.spec.ts │ │ └── remote-instances-list.service.ts │ ├── settings │ │ ├── settings.component.html │ │ ├── settings.component.scss │ │ ├── settings.component.spec.ts │ │ ├── settings.component.ts │ │ ├── settings.service.spec.ts │ │ └── settings.service.ts │ ├── shared │ │ ├── humanize.pipe.spec.ts │ │ ├── humanize.pipe.ts │ │ ├── latency-chart.directive.spec.ts │ │ ├── latency-chart.directive.ts │ │ ├── load-sparklines.directive.spec.ts │ │ ├── load-sparklines.directive.ts │ │ ├── map-to-iterable.pipe.spec.ts │ │ ├── map-to-iterable.pipe.ts │ │ ├── moment-format.pipe.spec.ts │ │ ├── moment-format.pipe.ts │ │ ├── parse-query-param-date.pipe.spec.ts │ │ ├── parse-query-param-date.pipe.ts │ │ ├── shared.module.ts │ │ ├── sorting-table.pipe.spec.ts │ │ ├── sorting-table.pipe.ts │ │ ├── truncate-root.pipe.spec.ts │ │ └── truncate-root.pipe.ts │ └── summary │ │ ├── summary.component.html │ │ ├── summary.component.scss │ │ ├── summary.component.spec.ts │ │ ├── summary.component.ts │ │ ├── summary.service.spec.ts │ │ └── summary.service.ts ├── assets │ ├── .gitkeep │ ├── 50747258.png │ ├── Percona_XtraDB_Cluster.png │ ├── background.png │ ├── database.png │ ├── footer-logo.png │ ├── logo.png │ ├── mysql_64_black.png │ ├── percona-server-black-50.png │ ├── pmm-logo.svg │ └── sm-logo.png ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── typings.d.ts ├── tsconfig.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .vscode/ 4 | .DS_Store 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6 4 | }, 5 | "rules": { 6 | "quotes": ["off"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This pre-post git hook makes only basic check of a build. 3 | # Please double check if you made a build over all you changes and committed it. 4 | # Use --no-verify flag on your risk. 5 | 6 | # checks if dist/qan-app/ is the newest againts node_modules/ and src/ folders. 7 | if [ `stat -f "%m" dist/qan-app/` -ge `stat -f "%m" node_modules/` -a `stat -f "%m" dist/qan-app/` -ge `stat -f "%m" src/` ] ; then 8 | # checks if dist/qan-app/ id added and commited. 9 | if git status | grep dist/qan-app/ ; then 10 | echo "Please add and commit dist/qan-app/ before push." 11 | exit 1 12 | else 13 | echo "Looks like dist/qan-app/ is up to date. Pushing to origin..." 14 | exit 0 15 | fi 16 | else 17 | echo "Looks like dist/qan-app/ is NOT up to date." 18 | echo "Please do npm run build, add dist/qan-app/ and commit." 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /tmp 5 | 6 | # dependencies 7 | /node_modules 8 | 9 | # IDEs and editors 10 | /.idea 11 | .project 12 | .classpath 13 | .c9/ 14 | *.launch 15 | .settings/ 16 | 17 | # IDE - VSCode 18 | .vscode/ 19 | !.vscode/settings.json 20 | !.vscode/tasks.json 21 | !.vscode/launch.json 22 | !.vscode/extensions.json 23 | 24 | # misc 25 | dist/qan-app-*.tar.gz 26 | /.sass-cache 27 | /connect.lock 28 | /coverage/* 29 | /libpeerconnection.log 30 | npm-debug.log 31 | testem.log 32 | /typings 33 | 34 | # e2e 35 | /e2e/*.js 36 | /e2e/*.map 37 | 38 | #System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | 3 | language: node_js 4 | node_js: 5 | - "10" 6 | 7 | # addons: 8 | # apt: 9 | # sources: 10 | # - google-chrome 11 | # packages: 12 | # - google-chrome-stable 13 | 14 | before_install: 15 | - sudo apt-get -yq --no-install-suggests --allow-unauthenticated --no-install-recommends $(travis_apt_get_options) install google-chrome-stable 16 | 17 | cache: 18 | directories: 19 | - ./node_modules 20 | 21 | install: 22 | - npm install 23 | 24 | script: 25 | # Use Chromium instead of Chrome. 26 | - export CHROME_BIN=google-chrome 27 | - export PATH=$(npm bin):$PATH 28 | - xvfb-run -a npm run lint 29 | # - xvfb-run -a npm run test -- --single-run --no-progress 30 | # - | 31 | # json-server --quiet --routes=routes.json db.js & 32 | # SERVER_PID=$! 33 | # - xvfb-run -a npm run e2e -- --proxy-config=proxy.conf.e2e.json --no-progress --config=protractor-ci.conf.js 34 | # - kill -9 $SERVER_PID 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | git config core.hooksPath .githooks 3 | npm ci 4 | 5 | build: 6 | rm -rf dist/* 7 | npm run build 8 | npm run pack 9 | deploy: 10 | docker exec pmm-server bash -c 'rm -rf /usr/share/percona-qan-app/*' 11 | docker cp dist/qan-app pmm-server:/opt/ 12 | docker exec pmm-server bash -c 'mv /opt/qan-app/* /usr/share/percona-qan-app/' 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QAN App 2 | 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/dda40c1517c749b989e26b2ad0cc4d63)](https://www.codacy.com/app/Percona/qan-app?utm_source=github.com&utm_medium=referral&utm_content=percona/qan-app&utm_campaign=Badge_Grade) 4 | [![CLA assistant](https://cla-assistant.percona.com/readme/badge/percona/qan-app)](https://cla-assistant.percona.com/percona/qan-app) 5 | 6 | ## Development server 7 | 8 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 9 | 10 | ## Code scaffolding 11 | 12 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive/pipe/service/class/module`. 13 | 14 | ## Build 15 | 16 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. 17 | 18 | ## Running unit tests 19 | 20 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 21 | 22 | ## Running end-to-end tests 23 | 24 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 25 | Before running the tests make sure you are serving the app via `ng serve`. 26 | 27 | ## Deploying to GitHub Pages 28 | 29 | Run `ng github-pages:deploy` to deploy to GitHub Pages. 30 | 31 | ## Further help 32 | 33 | To get more help on the `angular-cli` use `ng help` or go check out the [Angular-CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 34 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "qan-app": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "outputPath": "dist", 15 | "index": "src/index.html", 16 | "main": "src/main.ts", 17 | "tsConfig": "src/tsconfig.app.json", 18 | "polyfills": "src/polyfills.ts", 19 | "assets": [ 20 | "src/assets", 21 | "src/favicon.ico" 22 | ], 23 | "styles": [ 24 | "node_modules/font-awesome/scss/font-awesome.scss", 25 | "node_modules/bootstrap/scss/bootstrap.scss", 26 | "node_modules/highlight.js/styles/github-gist.css", 27 | "src/styles.scss" 28 | ], 29 | "scripts": [] 30 | }, 31 | "configurations": { 32 | "dev": { 33 | "fileReplacements": [ 34 | { 35 | "replace": "src/app/environment.ts", 36 | "with": "src/environments/environment.ts" 37 | } 38 | ] 39 | }, 40 | "production": { 41 | "optimization": true, 42 | "outputHashing": "all", 43 | "sourceMap": false, 44 | "extractCss": true, 45 | "namedChunks": false, 46 | "aot": true, 47 | "extractLicenses": true, 48 | "vendorChunk": false, 49 | "buildOptimizer": true, 50 | "fileReplacements": [ 51 | { 52 | "replace": "src/app/environment.ts", 53 | "with": "src/environments/environment.prod.ts" 54 | } 55 | ] 56 | }, 57 | "demo": { 58 | "fileReplacements": [ 59 | { 60 | "replace": "src/app/environment.ts", 61 | "with": "src/environments/environment.demo.ts" 62 | } 63 | ] 64 | }, 65 | "navless": { 66 | "fileReplacements": [ 67 | { 68 | "replace": "src/app/environment.ts", 69 | "with": "src/environments/environment.navless.ts" 70 | } 71 | ] 72 | } 73 | } 74 | }, 75 | "serve": { 76 | "builder": "@angular-devkit/build-angular:dev-server", 77 | "options": { 78 | "browserTarget": "qan-app:build" 79 | }, 80 | "configurations": { 81 | "production": { 82 | "browserTarget": "qan-app:build:production" 83 | } 84 | } 85 | }, 86 | "extract-i18n": { 87 | "builder": "@angular-devkit/build-angular:extract-i18n", 88 | "options": { 89 | "browserTarget": "qan-app:build" 90 | } 91 | }, 92 | "test": { 93 | "builder": "@angular-devkit/build-angular:karma", 94 | "options": { 95 | "main": "src/test.ts", 96 | "karmaConfig": "./karma.conf.js", 97 | "polyfills": "src/polyfills.ts", 98 | "tsConfig": "src/tsconfig.spec.json", 99 | "codeCoverage": true, 100 | "scripts": [], 101 | "styles": [ 102 | "node_modules/font-awesome/scss/font-awesome.scss", 103 | "node_modules/bootstrap/scss/bootstrap.scss", 104 | "node_modules/highlight.js/styles/github-gist.css", 105 | "src/styles.scss" 106 | ], 107 | "assets": [ 108 | "src/assets", 109 | "src/favicon.ico" 110 | ] 111 | } 112 | }, 113 | "lint": { 114 | "builder": "@angular-devkit/build-angular:tslint", 115 | "options": { 116 | "tsConfig": [ 117 | "src/tsconfig.app.json", 118 | "src/tsconfig.spec.json" 119 | ], 120 | "exclude": [] 121 | } 122 | } 123 | } 124 | }, 125 | "qan-app-e2e": { 126 | "root": "e2e", 127 | "sourceRoot": "e2e", 128 | "projectType": "application", 129 | "architect": { 130 | "e2e": { 131 | "builder": "@angular-devkit/build-angular:protractor", 132 | "options": { 133 | "protractorConfig": "./protractor.conf.js", 134 | "devServerTarget": "qan-app:serve" 135 | } 136 | }, 137 | "lint": { 138 | "builder": "@angular-devkit/build-angular:tslint", 139 | "options": { 140 | "tsConfig": [ 141 | "e2e/tsconfig.e2e.json" 142 | ], 143 | "exclude": [] 144 | } 145 | } 146 | } 147 | } 148 | }, 149 | "defaultProject": "qan-app", 150 | "schematics": { 151 | "@schematics/angular:component": { 152 | "inlineTemplate": false, 153 | "spec": true, 154 | "prefix": "app", 155 | "styleext": "css" 156 | }, 157 | "@schematics/angular:directive": { 158 | "prefix": "app" 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | "instances": require('./jsons/qan-api/instances.json').instances, 4 | flight: require('./jsons/1.json') 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /dist/qan-app/assets/50747258.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/50747258.png -------------------------------------------------------------------------------- /dist/qan-app/assets/Percona_XtraDB_Cluster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/Percona_XtraDB_Cluster.png -------------------------------------------------------------------------------- /dist/qan-app/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/background.png -------------------------------------------------------------------------------- /dist/qan-app/assets/database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/database.png -------------------------------------------------------------------------------- /dist/qan-app/assets/footer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/footer-logo.png -------------------------------------------------------------------------------- /dist/qan-app/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/logo.png -------------------------------------------------------------------------------- /dist/qan-app/assets/mysql_64_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/mysql_64_black.png -------------------------------------------------------------------------------- /dist/qan-app/assets/percona-server-black-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/percona-server-black-50.png -------------------------------------------------------------------------------- /dist/qan-app/assets/pmm-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 60 | 68 | 69 | 70 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | -------------------------------------------------------------------------------- /dist/qan-app/assets/sm-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/assets/sm-logo.png -------------------------------------------------------------------------------- /dist/qan-app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/favicon.ico -------------------------------------------------------------------------------- /dist/qan-app/fontawesome-webfont.674f50d287a8c48dc19b.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/fontawesome-webfont.674f50d287a8c48dc19b.eot -------------------------------------------------------------------------------- /dist/qan-app/fontawesome-webfont.af7ae505a9eed503f8b8.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/fontawesome-webfont.af7ae505a9eed503f8b8.woff2 -------------------------------------------------------------------------------- /dist/qan-app/fontawesome-webfont.b06871f281fee6b241d6.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/fontawesome-webfont.b06871f281fee6b241d6.ttf -------------------------------------------------------------------------------- /dist/qan-app/fontawesome-webfont.fee66e712a8a08eef580.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/fontawesome-webfont.fee66e712a8a08eef580.woff -------------------------------------------------------------------------------- /dist/qan-app/footer-logo.f5f92e041aab6e0a0731.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/dist/qan-app/footer-logo.f5f92e041aab6e0a0731.png -------------------------------------------------------------------------------- /dist/qan-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Percona Query Analytics 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /dist/qan-app/pmm-logo.774290ea45f094ac7586.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 60 | 68 | 69 | 70 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | -------------------------------------------------------------------------------- /dist/qan-app/runtime.458556a34b891ea32398.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,l,f=r[0],i=r[1],p=r[2],c=0,s=[];c { 7 | page = new QanAppPage(); 8 | }); 9 | 10 | it('should display message saying app works', () => { 11 | page.navigateTo(); 12 | expect(page.isMainPresent()).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, element, by } from 'protractor'; 2 | 3 | export class QanAppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root')).getText(); 10 | } 11 | 12 | isMainPresent() { 13 | return $('app-root').$('main').isPresent(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": [ 9 | "es2016", 10 | "dom" 11 | ], 12 | "outDir": "../dist/out-tsc-e2e", 13 | "module": "commonjs", 14 | "target": "es6", 15 | "types":[ 16 | "jasmine", 17 | "node" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /jsons/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "posts": [ 3 | { "id": 1, "title": "json-server", "author": "typicode" } 4 | ], 5 | "comments": [ 6 | { "id": 1, "body": "some comment", "postId": 1 } 7 | ], 8 | "profile": { "name": "typicode" } 9 | } 10 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/0.13/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | browsers: ['ChromeNoSandbox'], 16 | customLaunchers: { 17 | ChromeNoSandbox: { 18 | base: 'Chrome', 19 | flags: ['--no-sandbox'] 20 | } 21 | }, 22 | client: { 23 | clearContext: false // leave Jasmine Spec Runner output visible in browser 24 | }, 25 | files: [ ], 26 | preprocessors: { }, 27 | mime: { 28 | 'text/x-typescript': ['ts', 'tsx'] 29 | }, 30 | coverageIstanbulReporter: { 31 | reports: [ 'html', 'lcovonly' ], 32 | fixWebpackSourcePaths: true, 33 | thresholds: { 34 | statements: 80, 35 | lines: 80, 36 | branches: 80, 37 | functions: 80 38 | } 39 | }, 40 | reporters: config.angularCli && config.angularCli.codeCoverage ? ['progress', 'coverage-istanbul', 'kjhtml'] : ['progress', 'kjhtml'], 41 | port: 9876, 42 | colors: true, 43 | logLevel: config.LOG_INFO, 44 | autoWatch: true, 45 | singleRun: false, 46 | browserNoActivityTimeout: 100000 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qan-app", 3 | "version": "v1.17.5", 4 | "license": "AGPL-3.0", 5 | "angular-cli": {}, 6 | "scripts": { 7 | "ng": "PATH=$(npm bin):$PATH; ng", 8 | "start": "ng serve --proxy-config proxy.conf.json", 9 | "lint": "ng lint", 10 | "format": "tsfmt -r", 11 | "test": "ng test", 12 | "test:coverage-report": "open coverage/index.html", 13 | "e2e": "ng e2e --proxy-config proxy.conf.json", 14 | "server": "PATH=$(npm bin):$PATH; webpack-dev-server --config webpack.config.js", 15 | "build": "ng build qan-app --base-href '/qan/' --aot --output-path 'dist/qan-app' --prod --progress --output-hashing=all", 16 | "pack": "cd dist/; tar -czf qan-app-`date +%Y%m%dT%H%m%S`.tar.gz qan-app", 17 | "clean": "rm -r dist/*" 18 | }, 19 | "private": true, 20 | "dependencies": { 21 | "@angular/common": "^8.1.1", 22 | "@angular/compiler": "^8.1.1", 23 | "@angular/core": "^8.1.1", 24 | "@angular/forms": "^8.1.1", 25 | "@angular/material": "^8.0.2", 26 | "@angular/platform-browser": "^8.1.1", 27 | "@angular/platform-browser-dynamic": "^8.1.1", 28 | "@angular/router": "^8.1.1", 29 | "@ng-bootstrap/ng-bootstrap": "^4.2.1", 30 | "@ng-select/ng-select": "^2.20.5", 31 | "@types/d3-shape": "^1.3.1", 32 | "@types/jszip": "^3.1.6", 33 | "bootstrap": "^4.3.1", 34 | "core-js": "^2.6.9", 35 | "d3": "^5.9.7", 36 | "font-awesome": "4.7.0", 37 | "highlight.js": "10.4.1", 38 | "hoek": "^5.0.4", 39 | "jszip": "^3.2.2", 40 | "moment": "^2.24.0", 41 | "ngx-clipboard": "^12.2.0", 42 | "ngx-pagination": "^3.3.1", 43 | "ngx-perfect-scrollbar": "^7.2.1", 44 | "numeral": "2.0.6", 45 | "perfect-scrollbar": "^1.4.0", 46 | "renderjson": "github:caldwell/renderjson", 47 | "rxjs": "^6.5.2", 48 | "stream": "0.0.2", 49 | "ts-helpers": "1.1.2", 50 | "tslib": "^1.10.0", 51 | "vkbeautify": "^0.99.3", 52 | "zone.js": "~0.9.1", 53 | "lodash.template": ">=4.5.0", 54 | "serialize-javascript": ">=2.1.1" 55 | }, 56 | "devDependencies": { 57 | "@angular-devkit/build-angular": "~0.803.27", 58 | "@angular/cli": "^8.1.1", 59 | "@angular/compiler-cli": "^8.1.1", 60 | "@types/d3": "5.0.0", 61 | "@types/highlight.js": "9.12.3", 62 | "@types/jasmine": "2.8.8", 63 | "@types/node": "^10.14.12", 64 | "@types/numeral": "0.0.25", 65 | "@types/vkbeautify": "0.99.2", 66 | "acorn": "6.4.1", 67 | "codelyzer": "^5.0.1", 68 | "handlebars": "^4.7.6", 69 | "jasmine": "^3.4.0", 70 | "jasmine-core": "^3.4.0", 71 | "jasmine-spec-reporter": "4.2.1", 72 | "json-server": "^0.14.2", 73 | "karma": "^3.1.4", 74 | "karma-chrome-launcher": "2.2.0", 75 | "karma-cli": "1.0.1", 76 | "karma-coverage-istanbul-reporter": "^2.0.5", 77 | "karma-jasmine": "1.1.2", 78 | "karma-jasmine-html-reporter": "^1.4.2", 79 | "karma-remap-istanbul": "0.6.0", 80 | "kind-of": "^6.0.3", 81 | "minimist": "^1.2.5", 82 | "ng-swagger-gen": "^1.7.1", 83 | "protractor": "5.4.0", 84 | "raw-loader": "^0.5.1", 85 | "readable-stream": "^2.3.6", 86 | "sass-loader": "^7.1.0", 87 | "ts-node": "^7.0.1", 88 | "tslint": "5.11.0", 89 | "typescript": "3.4.5", 90 | "typescript-formatter": "^7.2.2" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /protractor-ci.conf.js: -------------------------------------------------------------------------------- 1 | const config = require('./protractor.conf').config; 2 | 3 | config.capabilities = { 4 | browserName: 'chrome', 5 | chromeOptions: { 6 | args: ['--no-sandbox'] 7 | } 8 | }; 9 | 10 | exports.config = config; 11 | -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | /*global jasmine */ 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | exports.config = { 8 | allScriptsTimeout: 11000, 9 | specs: [ 10 | './e2e/**/*.e2e-spec.ts' 11 | ], 12 | capabilities: { 13 | 'browserName': 'chrome' 14 | }, 15 | directConnect: true, 16 | baseUrl: 'http://localhost:4200/', 17 | framework: 'jasmine', 18 | jasmineNodeOpts: { 19 | showColors: true, 20 | defaultTimeoutInterval: 30000, 21 | print: function() {} 22 | }, 23 | beforeLaunch: function() { 24 | require('ts-node').register({ 25 | project: 'e2e/tsconfig.e2e.json' 26 | }); 27 | }, 28 | onPrepare: function() { 29 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /proxy.conf.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "/qan-api": { 3 | "target": "http://localhost:3000", 4 | "secure": false, 5 | "logLevel": "debug" 6 | }, 7 | "/managed": { 8 | "target": "http://localhost", 9 | "secure": false, 10 | "logLevel": "debug" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/qan-api": { 3 | "target": "http://localhost", 4 | "secure": false, 5 | "logLevel": "debug" 6 | }, 7 | "/managed": { 8 | "target": "http://localhost", 9 | "secure": false, 10 | "logLevel": "debug" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/qan-api/*": "/$1" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/add-amazon-rds/add-amazon-rds.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Amazon RDS Credentials

4 |
5 |
6 |
7 |
8 |
9 | 11 |
12 |
13 |
14 |
15 | 17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 | 27 |
28 |
29 | 30 |
31 |
32 |
33 |

Amazon RDS Instances

34 |
35 |
36 | 37 | 41 | 42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 70 | 71 | 72 | 77 | 82 | 86 | 87 | 88 | 89 |
NameRegionEndpointEngineEnabled
{{ instance.node.name }}{{ instance.node.region }}{{ instance.service.address }}:{{ instance.service.port }}{{ instance.service.engine }} v{{ instance.service.engine_version }} 62 | 64 | 65 | 66 | 67 | 68 | 69 |
73 |
74 | 75 |
76 |
78 |
79 | 80 |
81 |
83 | 84 | 85 |
90 |
91 |
92 | -------------------------------------------------------------------------------- /src/app/add-amazon-rds/add-amazon-rds.component.scss: -------------------------------------------------------------------------------- 1 | .aws-table { 2 | width: 100%; 3 | } 4 | 5 | .not-checked-button { 6 | padding: .375rem .75rem; 7 | } 8 | 9 | .not-checked-button:not(:last-child) { 10 | margin-right: 20px; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/add-amazon-rds/add-amazon-rds.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AddAmazonRDSComponent } from './add-amazon-rds.component'; 4 | 5 | describe('AddAmazonRDSComponent', () => { 6 | let component: AddAmazonRDSComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AddAmazonRDSComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AddAmazonRDSComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/add-amazon-rds/add-amazon-rds.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { RDSCredentials, MySQLCredentials, RDSInstance, RDSNode, AddAmazonRDSService } from './add-amazon-rds.service' 3 | import { environment } from '../environment'; 4 | 5 | @Component({ 6 | selector: 'app-add-aws', 7 | templateUrl: './add-amazon-rds.component.html', 8 | styleUrls: ['./add-amazon-rds.component.scss'] 9 | }) 10 | export class AddAmazonRDSComponent implements OnInit { 11 | 12 | rdsCredentials = new RDSCredentials(); 13 | mysqlCredentials = new MySQLCredentials(); 14 | rdsNode = {} as RDSNode; 15 | allRDSInstances: RDSInstance[] = []; 16 | registeredRDSInstances: RDSInstance[] = []; 17 | registeredNames: string[] = []; 18 | isLoading: boolean; 19 | errorMessage: string; 20 | isDemo = false; 21 | submitted = false; 22 | 23 | constructor(public addAmazonRDSService: AddAmazonRDSService) { 24 | this.isDemo = environment.demoHosts.includes(location.hostname); 25 | } 26 | 27 | async onSubmit() { 28 | this.errorMessage = ''; 29 | this.isLoading = true; 30 | this.submitted = true; 31 | try { 32 | this.allRDSInstances = await this.addAmazonRDSService.discover(this.rdsCredentials); 33 | await this.getRegistered(); 34 | this.errorMessage = ''; 35 | } catch (err) { 36 | this.allRDSInstances = []; 37 | let msg = err.json().error; 38 | if (msg.startsWith('NoCredentialProviders')) { 39 | msg = 'Cannot discover instances - please provide Amazon RDS access credentials'; 40 | } 41 | this.errorMessage = msg; 42 | } finally { 43 | this.isLoading = false; 44 | } 45 | } 46 | 47 | enableInstanceMonitoring(node: RDSNode) { 48 | this.mysqlCredentials = new MySQLCredentials(); 49 | this.rdsNode = { name: node.name, region: node.region } as RDSNode; 50 | } 51 | 52 | showConnect(node: RDSNode): boolean { 53 | return this.rdsNode.name === node.name && this.rdsNode.region === node.region; 54 | } 55 | 56 | cancel() { 57 | this.rdsNode.name = ''; 58 | this.rdsNode.region = ''; 59 | } 60 | 61 | async onConnect() { 62 | this.errorMessage = ''; 63 | try { 64 | const res = await this.addAmazonRDSService.enable(this.rdsCredentials, this.rdsNode, this.mysqlCredentials); 65 | } catch (err) { 66 | this.errorMessage = err.json().error; 67 | return; 68 | } 69 | this.rdsNode = {} as RDSNode; 70 | this.cancel(); 71 | await this.getRegistered(); 72 | } 73 | 74 | async disableInstanceMonitoring(node: RDSNode) { 75 | if (this.isDemo) { 76 | return false; 77 | } 78 | this.errorMessage = ''; 79 | const text = `Are you sure want to disable monitoring of '${node.name}:${node.region}' node?`; 80 | if (confirm(text)) { 81 | try { 82 | const res = await this.addAmazonRDSService.disable(node); 83 | await this.getRegistered(); 84 | } catch (err) { 85 | this.errorMessage = err.json().error; 86 | } 87 | } 88 | } 89 | 90 | isEnabled(rdsInstance: RDSInstance): boolean { 91 | return this.registeredNames.indexOf(rdsInstance.node.name + ':' + rdsInstance.node.region) > -1; 92 | } 93 | 94 | async getRegistered() { 95 | this.errorMessage = ''; 96 | try { 97 | this.registeredRDSInstances = await this.addAmazonRDSService.getRegistered(); 98 | } catch (err) { 99 | this.errorMessage = err.json().error; 100 | } 101 | this.registeredNames = []; 102 | if (this.registeredRDSInstances !== undefined) { 103 | this.registeredRDSInstances.forEach(element => { 104 | this.registeredNames.push(element.node.name + ':' + element.node.region); 105 | }); 106 | } 107 | } 108 | 109 | async ngOnInit() { 110 | this.errorMessage = ''; 111 | try { 112 | const allRDSInstances = await this.addAmazonRDSService.discover(this.rdsCredentials); 113 | if (this.submitted) { // ignore results if user submitted form with creds. 114 | return; 115 | } 116 | await this.getRegistered(); 117 | this.allRDSInstances = allRDSInstances; 118 | this.errorMessage = ''; 119 | 120 | } catch (err) { 121 | if (this.submitted) { // ignore results if user submitted form with creds. 122 | return; 123 | } 124 | let msg = err.json().error; 125 | if (msg.startsWith('NoCredentialProviders')) { 126 | msg = 'Cannot automatically discover instances - please provide Amazon RDS access credentials'; 127 | } 128 | this.errorMessage = msg; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/app/add-amazon-rds/add-amazon-rds.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { AddAmazonRDSService } from './add-amazon-rds.service'; 4 | 5 | describe('AddAmazonRDSService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [AddAmazonRDSService] 9 | }); 10 | }); 11 | 12 | it('should be created', inject([AddAmazonRDSService], (service: AddAmazonRDSService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/add-amazon-rds/add-amazon-rds.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient, HttpHeaders} from '@angular/common/http'; 3 | 4 | export class RDSCredentials { 5 | constructor(public aws_access_key_id = '', public aws_secret_access_key = '') { 6 | } 7 | } 8 | 9 | export class MySQLCredentials { 10 | constructor(public username = '', public password = '') { 11 | } 12 | } 13 | 14 | export interface RDSNode { 15 | name: string; 16 | region: string; 17 | } 18 | 19 | export interface RDSService { 20 | address: string; 21 | port: number; 22 | engine: string; 23 | engine_version: string; 24 | } 25 | 26 | export interface RDSInstance { 27 | node: RDSNode; 28 | service: RDSService; 29 | } 30 | 31 | @Injectable() 32 | export class AddAmazonRDSService { 33 | 34 | private headers = new HttpHeaders({'Content-Type': 'application/json'}); 35 | 36 | constructor(private httpClient: HttpClient) { 37 | } 38 | 39 | async discover(rdsCredentials: RDSCredentials): Promise { 40 | const url = `/managed/v0/rds/discover`; 41 | const data = { 42 | aws_access_key_id: rdsCredentials.aws_access_key_id, 43 | aws_secret_access_key: rdsCredentials.aws_secret_access_key 44 | }; 45 | const response = await this.httpClient 46 | .post(url, data, {headers: this.headers}) 47 | .toPromise(); 48 | return response['instances'] as RDSInstance[]; 49 | } 50 | 51 | async enable(rdsCredentials: RDSCredentials, node: RDSNode, mysqlCredentials: MySQLCredentials): Promise<{}> { 52 | const url = `/managed/v0/rds`; 53 | const data = { 54 | aws_access_key_id: rdsCredentials.aws_access_key_id, 55 | aws_secret_access_key: rdsCredentials.aws_secret_access_key, 56 | id: {name: node.name, region: node.region}, 57 | password: mysqlCredentials.password, 58 | username: mysqlCredentials.username 59 | }; 60 | return await this.httpClient 61 | .post(url, data, {headers: this.headers}) 62 | .toPromise(); 63 | } 64 | 65 | async disable(node: RDSNode): Promise<{}> { 66 | const url = `/managed/v0/rds`; 67 | const body = {id: {name: node.name, region: node.region}}; 68 | return await this.httpClient.request('delete', url, {body: body}) 69 | .toPromise(); 70 | } 71 | 72 | async getRegistered(): Promise { 73 | const url = `/managed/v0/rds`; 74 | const response = await this.httpClient 75 | .get(url, {headers: this.headers}) 76 | .toPromise(); 77 | return response['instances'] as RDSInstance[]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/add-instance/add-instance.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

How to Add an Instance

4 |
5 |
6 | 46 | -------------------------------------------------------------------------------- /src/app/add-instance/add-instance.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/app/add-instance/add-instance.component.scss -------------------------------------------------------------------------------- /src/app/add-instance/add-instance.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AddInstanceComponent } from './add-instance.component'; 4 | 5 | describe('AddInstanceComponent', () => { 6 | let component: AddInstanceComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AddInstanceComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AddInstanceComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/add-instance/add-instance.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-add-instance', 5 | templateUrl: './add-instance.component.html', 6 | styleUrls: ['./add-instance.component.scss'] 7 | }) 8 | export class AddInstanceComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/add-remote-instances/add-remote-instance.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |

Add remote {{instanceType}} Instance

9 |
10 |
11 |
12 |
13 |
14 | 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 | 36 |
37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 | 53 |
54 |
55 |
56 | 57 |
58 |
59 |
60 |
61 | 62 |
63 |
64 |
65 | 71 |
72 |
73 |
74 | 75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 |
83 | 89 |
90 |
91 |
92 | 93 |
94 |
95 |
96 |
97 | 98 |
99 |
100 | 101 |
*{{ errorMessage }}
102 |
Fields marked with an asterisk (*) are required
103 |
104 | 105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 | -------------------------------------------------------------------------------- /src/app/add-remote-instances/add-remote-instance.component.scss: -------------------------------------------------------------------------------- 1 | .add-remote-instance { 2 | .header p { 3 | font-size: 1.3rem; 4 | color: var(--white); 5 | } 6 | 7 | .tab-pane { 8 | background-color: #1f1d1d 9 | } 10 | 11 | .input-wrapper { 12 | min-height: 75px; 13 | } 14 | 15 | input.custom-form-input { 16 | height: 38px; 17 | color: var(--white); 18 | font-weight: 300; 19 | border-radius: 0; 20 | border: 1px solid #2a2a2a; 21 | background-color: #141414 !important; 22 | } 23 | 24 | .custom-form-input--not-valid { 25 | border-color: red !important; 26 | } 27 | 28 | form.content { 29 | padding: 40px 0; 30 | } 31 | 32 | input[type=text] { 33 | margin-bottom: 5px; 34 | border-radius: 3px; 35 | } 36 | 37 | input::placeholder { 38 | font-style: italic; 39 | } 40 | 41 | .not-checked-button { 42 | padding: .375rem .75rem; 43 | cursor: pointer; 44 | } 45 | 46 | .not-checked-button + div { 47 | margin-left: 20px; 48 | } 49 | 50 | .fa-info-circle { 51 | font-size: 20px; 52 | } 53 | 54 | .app-tooltip-wrapper { 55 | position: relative; 56 | } 57 | 58 | div.app-tooltip--remote-instance { 59 | cursor: pointer; 60 | } 61 | 62 | div.app-tooltip--remote-instance::before { 63 | content: 'Information'; 64 | left: 25px; 65 | top: -3px; 66 | padding: 5px; 67 | } 68 | 69 | div.app-tooltip--hostname::before { 70 | content: 'Public DNS hostname of your instance'; 71 | } 72 | 73 | div.app-tooltip--name::before { 74 | content: 'Instance name to use'; 75 | } 76 | 77 | div.app-tooltip--port::before { 78 | content: 'Port your instance is listening on'; 79 | } 80 | 81 | div.app-tooltip--username::before { 82 | content: 'Your database user name'; 83 | } 84 | 85 | div.app-tooltip--password::before { 86 | content: 'Your database password'; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/add-remote-instances/add-remote-instance.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit} from '@angular/core'; 2 | import {AddRemoteInstanceService, RemoteInstanceCredentials} from './add-remote-instance.service' 3 | import {environment} from '../environment'; 4 | import {Router} from '@angular/router'; 5 | 6 | @Component({ 7 | selector: 'app-add-remote-postgres', 8 | templateUrl: './add-remote-instance.component.html', 9 | styleUrls: ['./add-remote-instance.component.scss'] 10 | }) 11 | export class AddRemoteInstanceComponent implements OnInit { 12 | 13 | remoteInstanceCredentials = {} as RemoteInstanceCredentials; 14 | errorMessage: string; 15 | isDemo = false; 16 | isLoading = false; 17 | isSubmitted = false; 18 | instanceType: string; 19 | currentUrl: string; 20 | 21 | constructor(public addRemoteInstanceService: AddRemoteInstanceService, private router: Router) { 22 | this.isDemo = environment.demoHosts.includes(location.hostname); 23 | this.currentUrl = this.router.url; 24 | } 25 | 26 | async ngOnInit() { 27 | this.errorMessage = ''; 28 | this.isLoading = false; 29 | this.instanceType = 30 | this.addRemoteInstanceService.checkInstanceType(this.currentUrl) === 'postgresql' ? 'PostgreSQL' : 'MySQL'; 31 | } 32 | 33 | async onSubmit(form) { 34 | const currentUrl = `${window.parent.location}`; 35 | const newURL = currentUrl.split('/graph/d/').shift() + '/graph/d/pmm-list/'; 36 | 37 | this.errorMessage = ''; 38 | this.isSubmitted = true; 39 | if (!form.valid) { return; } 40 | this.isLoading = true; 41 | 42 | if (this.remoteInstanceCredentials.name === undefined || this.remoteInstanceCredentials.name === '') { 43 | this.remoteInstanceCredentials.name = this.remoteInstanceCredentials.address; // set default value for name (like address) 44 | } 45 | 46 | if (this.remoteInstanceCredentials.port === undefined || this.remoteInstanceCredentials.port === '') { 47 | this.remoteInstanceCredentials.port = this.instanceType === 'PostgreSQL' ? '5432' : '3306'; // set default value for port 48 | } 49 | 50 | try { 51 | const res = await this.addRemoteInstanceService.enable(this.remoteInstanceCredentials, this.currentUrl) 52 | .then(() => { 53 | window.parent.location.assign(newURL); 54 | }); 55 | 56 | } catch (err) { 57 | this.errorMessage = err.json().error; 58 | } 59 | this.isLoading = false; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/add-remote-instances/add-remote-instance.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient, HttpHeaders} from '@angular/common/http'; 3 | 4 | export interface RemoteInstanceCredentials { 5 | address: string; 6 | name: string; 7 | port: string; 8 | username: string; 9 | password: string; 10 | } 11 | 12 | export interface RemoteInstanceNode { 13 | id: number 14 | name: string 15 | region: string 16 | } 17 | 18 | export interface RemoteInstanceService { 19 | type: string; 20 | address: string; 21 | port: number; 22 | engine: string; 23 | engine_version: string; 24 | } 25 | 26 | export interface RemoteInstance { 27 | node: RemoteInstanceNode; 28 | service: RemoteInstanceService; 29 | } 30 | 31 | @Injectable() 32 | export class AddRemoteInstanceService { 33 | 34 | private headers = new HttpHeaders({'Content-Type': 'application/json'}); 35 | instanceUrlPart: string; 36 | 37 | constructor(private http: HttpClient) { 38 | } 39 | 40 | async enable(remoteInstanceCredentials: RemoteInstanceCredentials, currentUrl): Promise<{}> { 41 | this.instanceUrlPart = this.checkInstanceType(currentUrl); 42 | 43 | const url = `/managed/v0/${this.instanceUrlPart}`; 44 | const data = { 45 | address: remoteInstanceCredentials.address, 46 | name: remoteInstanceCredentials.name, 47 | port: remoteInstanceCredentials.port, 48 | password: remoteInstanceCredentials.password, 49 | username: remoteInstanceCredentials.username 50 | }; 51 | return await this.http 52 | .post(url, data, {headers: this.headers}) 53 | .toPromise(); 54 | } 55 | 56 | /** 57 | * Returns type of remote instance 58 | * @param currentUrl current page url 59 | */ 60 | checkInstanceType(currentUrl) { 61 | return currentUrl === '/add-remote-postgres' ? 'postgresql' : 'mysql'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, Injectable } from '@angular/core'; 2 | import { Routes, Router, RouterModule, CanActivate } from '@angular/router'; 3 | import { PageNotFoundComponent } from './core/page-not-found/page-not-found.component'; 4 | 5 | import { QueryProfileComponent } from './query-profile/query-profile.component'; 6 | import { MySQLQueryDetailsComponent } from './mysql-query-details/mysql-query-details.component'; 7 | import { MongoQueryDetailsComponent } from './mongo-query-details/mongo-query-details.component'; 8 | import { SummaryComponent } from './summary/summary.component'; 9 | import { SettingsComponent } from './settings/settings.component'; 10 | import { AddInstanceComponent } from './add-instance/add-instance.component'; 11 | import { AddAmazonRDSComponent } from './add-amazon-rds/add-amazon-rds.component'; 12 | import { InstanceService } from './core/instance.service'; 13 | import { AddRemoteInstanceComponent } from './add-remote-instances/add-remote-instance.component'; 14 | import {RemoteInstancesListComponent} from './remote-instances-list/remote-instances-list.component'; 15 | 16 | @Injectable() 17 | export class RegisteredInstanceGuard implements CanActivate { 18 | 19 | private existsRegisteredInstances: boolean; 20 | 21 | constructor(public instanceService: InstanceService, public router: Router) { 22 | console.log('EmptyInstanceGuard.constructor', instanceService.dbServers); 23 | this.existsRegisteredInstances = instanceService.dbServers.length > 0; 24 | } 25 | canActivate() { 26 | if (!this.existsRegisteredInstances) { 27 | this.router.navigate(['add-instance']); 28 | } 29 | return this.existsRegisteredInstances; 30 | } 31 | } 32 | 33 | const routes: Routes = [ 34 | { path: '', redirectTo: 'profile', pathMatch: 'full', canActivate: [RegisteredInstanceGuard] }, 35 | { 36 | path: 'profile', component: QueryProfileComponent, canActivate: [RegisteredInstanceGuard], children: [ 37 | { path: 'report/mysql', component: MySQLQueryDetailsComponent, canActivate: [RegisteredInstanceGuard] }, 38 | { path: 'report/mongo', component: MongoQueryDetailsComponent, canActivate: [RegisteredInstanceGuard] } 39 | ] 40 | }, 41 | { path: 'sys-summary', component: SummaryComponent, pathMatch: 'full', canActivate: [RegisteredInstanceGuard] }, 42 | { path: 'settings', component: SettingsComponent, pathMatch: 'full', canActivate: [RegisteredInstanceGuard] }, 43 | { path: 'add-instance', component: AddInstanceComponent, pathMatch: 'full' }, 44 | { path: 'add-amazon-rds', component: AddAmazonRDSComponent, pathMatch: 'full' }, 45 | { path: 'add-remote-postgres', component: AddRemoteInstanceComponent, pathMatch: 'full' }, 46 | { path: 'add-remote-mysql', component: AddRemoteInstanceComponent, pathMatch: 'full' }, 47 | { path: 'pmm-list', component: RemoteInstancesListComponent, pathMatch: 'full' }, 48 | { path: '**', component: PageNotFoundComponent } 49 | ]; 50 | 51 | @NgModule({ 52 | imports: [RouterModule.forRoot(routes)], 53 | providers: [RegisteredInstanceGuard], 54 | exports: [RouterModule] 55 | }) 56 | export class AppRoutingModule { } 57 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | {{ version }} 12 |
13 |
14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | @media printer { 2 | footer { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { AppComponent } from './app.component'; 5 | import { AppRoutingModule } from './app-routing.module'; 6 | import { RouterTestingModule } from '@angular/router/testing'; 7 | 8 | describe('AppComponent', () => { 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [ RouterTestingModule, AppRoutingModule ], 12 | declarations: [ 13 | AppComponent 14 | ], 15 | }); 16 | TestBed.compileComponents(); 17 | }); 18 | 19 | it('should create the app', async(() => { 20 | const fixture = TestBed.createComponent(AppComponent); 21 | const app = fixture.debugElement.componentInstance; 22 | expect(app).toBeTruthy(); 23 | })); 24 | 25 | it(`should have as title 'app works!'`, async(() => { 26 | const fixture = TestBed.createComponent(AppComponent); 27 | const app = fixture.debugElement.componentInstance; 28 | expect(app.title).toEqual('app works!'); 29 | })); 30 | 31 | it('should render title in a h1 tag', async(() => { 32 | const fixture = TestBed.createComponent(AppComponent); 33 | fixture.detectChanges(); 34 | const compiled = fixture.debugElement.nativeElement; 35 | expect(compiled.querySelector('h1').textContent).toContain('app works!'); 36 | })); 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, OnInit } from '@angular/core'; 2 | 3 | import { InstanceService } from './core/instance.service'; 4 | import { environment } from './environment'; 5 | import * as moment from 'moment'; 6 | import {DOCUMENT} from '@angular/common'; 7 | 8 | @Component({ 9 | selector: 'app-root', 10 | templateUrl: './app.component.html', 11 | styleUrls: ['./app.component.scss'] 12 | }) 13 | export class AppComponent implements OnInit { 14 | title = 'Query Analytics'; 15 | version = environment.version; 16 | isDemo = false; 17 | hideNav = false; 18 | isInstancesListEmpty: boolean; 19 | theme = 'app-theme-light'; 20 | 21 | constructor(instanceService: InstanceService, @Inject(DOCUMENT) private document) { 22 | this.isDemo = environment.demoHosts.indexOf(location.hostname) > -1; 23 | // show message how to configure pmm-client. 24 | this.hideNav = this.inIframe() || instanceService.dbServers.length === 0; 25 | } 26 | 27 | /** 28 | * inIframe is used to show/hide navbar. 29 | */ 30 | inIframe(): boolean { 31 | let inIframe = false; 32 | try { 33 | inIframe = window.self !== window.top; 34 | } catch (e) { 35 | inIframe = true; 36 | } 37 | return inIframe; 38 | } 39 | 40 | getCookie(name) { 41 | return document.cookie.split('; ').reduce((r, v) => { 42 | const parts = v.split('='); 43 | return parts[0] === name ? decodeURIComponent(parts[1]) : r; 44 | }, ''); 45 | } 46 | 47 | setCookie(key, value) { 48 | const expireDays = moment().utc().add(7, 'y').toString(); 49 | document.cookie = `${key}=${value}; expires=${expireDays}; path=/`; 50 | } 51 | 52 | ngOnInit() { 53 | let res: any; 54 | res = this.getJsonFromUrl() 55 | const theme = res.theme || ''; 56 | if (theme === '') { 57 | this.theme = this.getCookie('theme'); 58 | } else if (theme === 'dark') { 59 | this.theme = 'app-theme-dark'; 60 | } else if (theme === 'light') { 61 | this.theme = 'app-theme-light'; 62 | } 63 | this.setCookie('theme', this.theme); 64 | this.document.body.className = this.theme; 65 | } 66 | 67 | getJsonFromUrl() { 68 | const query = location.search.substr(1); 69 | const result = {}; 70 | query.split('&').forEach(function(part) { 71 | const item = part.split('='); 72 | result[item[0]] = decodeURIComponent(item[1]); 73 | }); 74 | return result; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule, APP_INITIALIZER } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; 5 | 6 | import { AppComponent } from './app.component'; 7 | import { AppRoutingModule } from './app-routing.module'; 8 | import { CoreModule } from './core/core.module'; 9 | import { SharedModule } from './shared/shared.module'; 10 | 11 | import { InstanceService } from './core/instance.service'; 12 | import { AddAmazonRDSComponent } from './add-amazon-rds/add-amazon-rds.component'; 13 | import { AddRemoteInstanceComponent } from './add-remote-instances/add-remote-instance.component'; 14 | import { AddInstanceComponent } from './add-instance/add-instance.component'; 15 | import {HttpClientModule} from '@angular/common/http'; 16 | export function getInstances(instanceService: InstanceService) { 17 | return function () { return instanceService.getDBServers(); }; 18 | } 19 | 20 | @NgModule({ 21 | declarations: [ 22 | AppComponent, 23 | AddAmazonRDSComponent, 24 | AddRemoteInstanceComponent, 25 | AddInstanceComponent, 26 | ], 27 | imports: [ 28 | AppRoutingModule, 29 | BrowserModule, 30 | CoreModule, 31 | FormsModule, 32 | HttpClientModule, 33 | NgbModule.forRoot(), 34 | SharedModule, 35 | ], 36 | providers: [ 37 | InstanceService, 38 | { 39 | provide: APP_INITIALIZER, 40 | useFactory: getInstances, 41 | deps: [InstanceService], 42 | multi: true 43 | } 44 | ], 45 | bootstrap: [AppComponent] 46 | }) 47 | export class AppModule { } 48 | -------------------------------------------------------------------------------- /src/app/core/core.component.ts: -------------------------------------------------------------------------------- 1 | import {OnDestroy} from '@angular/core'; 2 | import {ParseQueryParamDatePipe} from '../shared/parse-query-param-date.pipe'; 3 | import {Event, Router, ActivatedRoute, NavigationEnd} from '@angular/router'; 4 | import * as moment from 'moment'; 5 | 6 | import {environment} from '../environment'; 7 | import {Subscription} from 'rxjs'; 8 | import {Instance, InstanceService} from './instance.service'; 9 | import {filter} from 'rxjs/operators'; 10 | 11 | export interface QueryParams { 12 | from?: string; 13 | to?: string; 14 | 'var-host'?: string; // | string[]; 15 | search?: string; 16 | queryID?: string; 17 | tz?: string; 18 | theme?: string; 19 | first_seen?: boolean; 20 | } 21 | 22 | /** 23 | * Base class for all components. 24 | */ 25 | export abstract class CoreComponent implements OnDestroy { 26 | 27 | public isDemo = false; 28 | protected routerSubscription: Subscription; 29 | public queryParams: QueryParams; 30 | public previousQueryParams: QueryParams; 31 | public agent: Instance | null; 32 | public dbServer: Instance | null; 33 | public dbServers: Array = []; 34 | public dbServerMap: { [key: string]: Instance } = {}; 35 | public from: any; 36 | public to: any; 37 | public isAllSelected: boolean; 38 | public isNotExistSelected: boolean; 39 | public isQueryDataAbsent: boolean; 40 | 41 | public fromUTCDate: string; 42 | public toUTCDate: string; 43 | parseQueryParamDatePipe = new ParseQueryParamDatePipe(); 44 | 45 | constructor(protected route: ActivatedRoute, protected router: Router, 46 | protected instanceService: InstanceService) { 47 | this.isDemo = environment.demoHosts.includes(location.hostname); 48 | this.dbServer = instanceService.dbServers[0]; 49 | this.agent = instanceService.dbServers[0].Agent; 50 | this.dbServers = instanceService.dbServers; 51 | this.dbServerMap = instanceService.dbServerMap; 52 | this.subscribeToRouter(); 53 | } 54 | 55 | /** 56 | * Extract and convert query parameters. 57 | * Trigger onChangeParams method (must be overridden) on route change. 58 | */ 59 | subscribeToRouter() { 60 | 61 | this.routerSubscription = this.router.events.pipe( 62 | filter((e: any) => e instanceof NavigationEnd) 63 | ) 64 | .subscribe((event: Event) => { 65 | this.queryParams = this.route.snapshot.queryParams as QueryParams; 66 | this.parseParams(); 67 | 68 | // trigger overriden method in child component 69 | this.onChangeParams(this.queryParams); 70 | 71 | this.previousQueryParams = Object.assign({}, this.queryParams); 72 | }); 73 | } 74 | 75 | parseParams() { 76 | this.isAllSelected = this.queryParams['var-host'] === 'All'; 77 | this.isQueryDataAbsent = (this.dbServer === null) && (!this.isAllSelected) && (!this.isNotExistSelected); 78 | try { 79 | this.dbServer = this.dbServerMap[this.queryParams['var-host']]; 80 | this.agent = this.dbServerMap[this.queryParams['var-host']].Agent; 81 | } catch (err) { 82 | if (this.queryParams.hasOwnProperty('var-host')) { 83 | this.dbServer = null; 84 | this.agent = null; 85 | this.isNotExistSelected = !this.isAllSelected; 86 | } else { 87 | this.dbServer = this.instanceService.dbServers[0]; 88 | this.agent = this.instanceService.dbServers[0].Agent; 89 | } 90 | } 91 | this.setTimeZoneFromParams(); 92 | this.setThemeFromParams(); 93 | this.from = this.parseQueryParamDatePipe.transform(this.queryParams.from, 'from'); 94 | this.to = this.parseQueryParamDatePipe.transform(this.queryParams.to, 'to'); 95 | this.fromUTCDate = this.from.utc().format('YYYY-MM-DDTHH:mm:ss'); 96 | this.toUTCDate = this.to.utc().format('YYYY-MM-DDTHH:mm:ss'); 97 | } 98 | 99 | /** 100 | * onChangeParams is invoked every time when route changes 101 | * @param params optional 102 | */ 103 | abstract onChangeParams(params): void; 104 | 105 | /** 106 | * set timezone based on given query parameter. 107 | */ 108 | setTimeZoneFromParams() { 109 | const tz = this.queryParams.tz || 'browser'; 110 | const expireDays = moment().utc().add(7, 'y').toString(); 111 | document.cookie = `timezone=${tz}; expires=${expireDays}; path=/`; 112 | } 113 | 114 | setThemeFromParams() { 115 | const theme = this.queryParams.theme || ''; 116 | if (theme) { 117 | const expireDays = moment().utc().add(7, 'y').toString(); 118 | document.cookie = `theme=app-theme-${theme}; expires=${expireDays}; path=/`; 119 | } 120 | } 121 | 122 | /** 123 | * Destroys route subscription on component unload. 124 | */ 125 | ngOnDestroy() { 126 | this.routerSubscription.unsubscribe(); 127 | } 128 | } 129 | 130 | 131 | export class QanError extends Error { 132 | static errType = 'QanError'; 133 | name = 'QanError'; 134 | } 135 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NgModule, 3 | Optional, SkipSelf 4 | } from '@angular/core'; 5 | 6 | import { CommonModule } from '@angular/common'; 7 | import { ErrorHandler } from '@angular/core'; 8 | 9 | import { SharedModule } from '../shared/shared.module'; 10 | import { NavComponent } from './nav/nav.component'; 11 | import { JSONTreeComponent } from './json-tree/json-tree.component'; 12 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; 13 | // import { QanErrorHandler } from './qan-error.handler'; 14 | import { InstanceService } from './instance.service'; 15 | import { CoreComponent } from './core.component'; 16 | 17 | import { QueryProfileComponent } from '../query-profile/query-profile.component'; 18 | import { QueryProfileService } from '../query-profile/query-profile.service'; 19 | import { MySQLQueryDetailsComponent } from '../mysql-query-details/mysql-query-details.component'; 20 | import { MySQLQueryDetailsService } from '../mysql-query-details/mysql-query-details.service'; 21 | import { MongoQueryDetailsComponent } from '../mongo-query-details/mongo-query-details.component'; 22 | import { MongoQueryDetailsService } from '../mongo-query-details/mongo-query-details.service'; 23 | import { SummaryComponent } from '../summary/summary.component'; 24 | import { SummaryService } from '../summary/summary.service'; 25 | import { SettingsComponent } from '../settings/settings.component'; 26 | import { SettingsService } from '../settings/settings.service'; 27 | import { AddAmazonRDSService } from '../add-amazon-rds/add-amazon-rds.service'; 28 | import { AddRemoteInstanceService } from '../add-remote-instances/add-remote-instance.service'; 29 | import { RemoteInstancesListService } from '../remote-instances-list/remote-instances-list.service'; 30 | import { ClipboardModule } from 'ngx-clipboard'; 31 | import {RemoteInstancesListComponent} from '../remote-instances-list/remote-instances-list.component'; 32 | 33 | @NgModule({ 34 | imports: [CommonModule, SharedModule, ClipboardModule], 35 | declarations: [NavComponent, PageNotFoundComponent, QueryProfileComponent, 36 | MySQLQueryDetailsComponent, MongoQueryDetailsComponent, 37 | SummaryComponent, SettingsComponent, JSONTreeComponent, RemoteInstancesListComponent], 38 | exports: [NavComponent, PageNotFoundComponent, QueryProfileComponent, 39 | MySQLQueryDetailsComponent, MongoQueryDetailsComponent, 40 | SummaryComponent, SettingsComponent, JSONTreeComponent], 41 | providers: [InstanceService, QueryProfileService, MySQLQueryDetailsService, 42 | MongoQueryDetailsService, SummaryService, SettingsService, 43 | AddAmazonRDSService, AddRemoteInstanceService, RemoteInstancesListService] 44 | }) 45 | export class CoreModule { 46 | 47 | constructor( @Optional() @SkipSelf() parentModule: CoreModule) { 48 | if (parentModule) { 49 | throw new Error( 50 | 'CoreModule is already loaded. Import it in the AppModule only'); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/core/instance.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { InstanceService } from './instance.service'; 4 | 5 | describe('InstanceService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [InstanceService] 9 | }); 10 | }); 11 | 12 | it('should ...', inject([InstanceService], (service: InstanceService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/core/instance.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {HttpClient} from '@angular/common/http'; 3 | 4 | export interface Instance { 5 | Created: string; 6 | DSN: string; 7 | Deleted: string; 8 | Distro: string; 9 | Id: number; 10 | Name: string; 11 | ParentUUID: string; 12 | Subsystem: string; 13 | UUID: string; 14 | Version: string; 15 | Agent?: Instance | null; 16 | } 17 | 18 | @Injectable() 19 | export class InstanceService { 20 | private instancesUrl = '/qan-api/instances?deleted=no'; 21 | public dbServers: Array = []; 22 | public dbServerMap: { [key: string]: Instance } = {}; 23 | constructor(private httpClient: HttpClient) { } 24 | 25 | public getDBServers(): Promise { 26 | return this.httpClient.get(this.instancesUrl) 27 | .toPromise() 28 | .then((response: any) => { 29 | const agents = response.filter( 30 | (i: Instance) => i.Subsystem === 'agent' 31 | ) as Instance[]; 32 | 33 | this.dbServers = (response.filter( 34 | (i: Instance) => i.Subsystem === 'mysql' || i.Subsystem === 'mongo' 35 | ) as Instance[]); 36 | 37 | const agentsByParentUUID: { [key: string]: Instance } = {}; 38 | for (const agent of agents) { 39 | agentsByParentUUID[agent.ParentUUID] = agent; 40 | } 41 | 42 | for (const srv of this.dbServers) { 43 | this.dbServerMap[srv.Name] = srv; 44 | this.dbServerMap[srv.Name].Agent = agentsByParentUUID[srv.ParentUUID]; 45 | } 46 | return this.dbServers; 47 | }) 48 | .catch(err => console.log(err)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/core/json-tree/json-tree.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 |
8 | -------------------------------------------------------------------------------- /src/app/core/json-tree/json-tree.component.scss: -------------------------------------------------------------------------------- 1 | // Copied and pasted 2 | ::ng-deep { 3 | .renderjson a { text-decoration: none; } 4 | .renderjson .disclosure { color: crimson; 5 | font-size: 150%; } 6 | .renderjson .syntax { color: grey; } 7 | .renderjson .string { color: red; } 8 | .renderjson .number { color: cyan; } 9 | .renderjson .boolean { color: plum; } 10 | .renderjson .key { color: lightblue; } 11 | .renderjson .keyword { color: lightgoldenrodyellow; } 12 | .renderjson .object.syntax { color: lightseagreen; } 13 | .renderjson .array.syntax { color: lightsalmon; } 14 | } 15 | 16 | .json-tree-wrapper { 17 | display: flex; 18 | justify-content: space-between; 19 | width: 100%; 20 | margin-right: 20px; 21 | } 22 | 23 | .pmm-button { 24 | position: fixed; 25 | right: 175px; 26 | } 27 | -------------------------------------------------------------------------------- /src/app/core/json-tree/json-tree.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, OnChanges, ElementRef} from '@angular/core'; 2 | import * as renderjson from 'renderjson'; 3 | 4 | @Component({ 5 | selector: 'app-json-tree', 6 | templateUrl: './json-tree.component.html', 7 | styleUrls: ['./json-tree.component.scss'] 8 | }) 9 | 10 | export class JSONTreeComponent implements OnChanges { 11 | public element: ElementRef; 12 | public isCollapsed = true; 13 | 14 | @Input() public json: any; 15 | 16 | constructor(element: ElementRef) { 17 | this.element = element; 18 | renderjson.set_icons('+', '-'); 19 | } 20 | 21 | ngOnChanges() { 22 | this.isCollapsed = true; 23 | this.resetJson(); 24 | } 25 | 26 | public toggleAll() { 27 | this.isCollapsed = !this.isCollapsed; 28 | this.resetJson(); 29 | } 30 | 31 | public resetJson() { 32 | renderjson.set_show_to_level(this.isCollapsed ? '' : 'all'); 33 | 34 | this.element.nativeElement.querySelector('#json-viewer').innerHTML = ''; 35 | this.element.nativeElement.querySelector('#json-viewer').appendChild(renderjson(this.json)); 36 | } 37 | } 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/app/core/nav/nav.component.html: -------------------------------------------------------------------------------- 1 | 104 | 105 | 106 |

Instance: {{ dbServer?.Name }}

107 |

DSN: {{ dbServer?.DSN?.split('/?')[0] }}

108 |

Version: {{ dbServer?.Version }} {{ dbServer?.Distro }}

109 |
110 | -------------------------------------------------------------------------------- /src/app/core/nav/nav.component.scss: -------------------------------------------------------------------------------- 1 | .img-db { 2 | width: 16px; 3 | height: 16px; 4 | } 5 | 6 | @media screen { 7 | .scrollable-list { 8 | margin: 0; 9 | padding: 0; 10 | min-width: 600px; 11 | max-width: 900px; 12 | max-height:70vh; 13 | overflow:scroll; 14 | } 15 | 16 | .printable-header { 17 | display: none; 18 | } 19 | 20 | .ribbon { 21 | position: absolute; 22 | right: -5px; top: -5px; 23 | z-index: -2; 24 | overflow: hidden; 25 | width: 75px; height: 75px; 26 | text-align: right; 27 | } 28 | .ribbon span { 29 | font-size: 10px; 30 | font-weight: bold; 31 | color: #FFF; 32 | text-transform: uppercase; 33 | text-align: center; 34 | line-height: 20px; 35 | transform: rotate(45deg); 36 | -webkit-transform: rotate(45deg); 37 | width: 100px; 38 | display: block; 39 | background: #79A70A; 40 | background: linear-gradient(#F79E05 0%, #8F5408 100%); 41 | box-shadow: 0 3px 10px -5px rgba(0, 0, 0, 1); 42 | position: absolute; 43 | top: 19px; right: -21px; 44 | } 45 | .ribbon span::before { 46 | content: ""; 47 | position: absolute; left: 0px; top: 100%; 48 | z-index: -1; 49 | border-left: 3px solid #8F5408; 50 | border-right: 3px solid transparent; 51 | border-bottom: 3px solid transparent; 52 | border-top: 3px solid #8F5408; 53 | } 54 | .ribbon span::after { 55 | content: ""; 56 | position: absolute; right: 0px; top: 100%; 57 | z-index: -1; 58 | border-left: 3px solid transparent; 59 | border-right: 3px solid #8F5408; 60 | border-bottom: 3px solid transparent; 61 | border-top: 3px solid #8F5408; 62 | } 63 | } 64 | 65 | @media print { 66 | nav { 67 | display: none; 68 | } 69 | 70 | .printable-header { 71 | display: block; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/core/nav/nav.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { DebugElement } from '@angular/core'; 5 | 6 | import { NavComponent } from './nav.component'; 7 | 8 | describe('NavComponent', () => { 9 | let component: NavComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async(() => { 13 | TestBed.configureTestingModule({ 14 | declarations: [ NavComponent ] 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(NavComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/core/nav/nav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy } from '@angular/core'; 2 | import { MomentFormatPipe } from '../../shared/moment-format.pipe'; 3 | import { QueryParams, CoreComponent } from '../core.component'; 4 | import { Router, ActivatedRoute } from '@angular/router'; 5 | import * as moment from 'moment'; 6 | 7 | import { environment } from '../../environment'; 8 | import {Subscription} from 'rxjs'; 9 | import {InstanceService} from '../instance.service'; 10 | 11 | @Component({ 12 | moduleId: module.id, 13 | selector: 'app-nav', 14 | templateUrl: 'nav.component.html', 15 | styleUrls: ['nav.component.scss'] 16 | }) 17 | export class NavComponent extends CoreComponent implements OnDestroy { 18 | protected routerSubscription: Subscription; 19 | 20 | public isDemo = false; 21 | 22 | public isExtHidden: boolean; 23 | 24 | public timezone: string; 25 | 26 | public fromDateCompact: string; 27 | 28 | public toDateCompact: string; 29 | 30 | private compactDateFormat = 'MMM D, YYYY HH:mm:ss'; 31 | 32 | private fromTimeRaw: string; 33 | private toTimeRaw: string; 34 | 35 | public isValidToInput = true; 36 | public isValidFromInput = true; 37 | public path: string; 38 | public hostSelectorPath = []; 39 | 40 | public constructor(route: ActivatedRoute, router: Router, instanceService: InstanceService) { 41 | super(route, router, instanceService); 42 | const momentFormatPipe = new MomentFormatPipe(); 43 | this.isDemo = environment.demoHosts.includes(location.hostname); 44 | this.timezone = momentFormatPipe.getCookie('timezone') || 'browser'; 45 | } 46 | 47 | onChangeParams(params) { 48 | // checks changing tz 49 | const momentFormatPipe = new MomentFormatPipe(); 50 | this.fromDateCompact = momentFormatPipe.transform(this.from, this.compactDateFormat); 51 | this.toDateCompact = momentFormatPipe.transform(this.to, this.compactDateFormat); 52 | const pathWithParams = this.router.url; 53 | this.path = pathWithParams.substr(0, pathWithParams.indexOf('?')); 54 | this.isExtHidden = true; 55 | this.hostSelectorPath = []; 56 | 57 | if (this.router.url.startsWith('/profile')) { 58 | this.isExtHidden = false; 59 | this.hostSelectorPath = ['/profile']; 60 | } 61 | } 62 | 63 | changeDateInput(event, dir) { 64 | const val = event.trim(); 65 | let time = ''; 66 | const reg = /^(now)([-/+])(\d+)([yMwdhms])$/; 67 | if (reg.test(val)) { 68 | const [, , sign, num, unit] = reg.exec(val); 69 | switch (sign) { 70 | case '-': 71 | time = moment().subtract(num as moment.unitOfTime.DurationConstructor, unit).valueOf().toString(); 72 | break; 73 | case '+': 74 | time = moment().add(num as moment.unitOfTime.DurationConstructor, unit).valueOf().toString(); 75 | break; 76 | case '/': 77 | time = moment().startOf(unit as moment.unitOfTime.StartOf).valueOf().toString(); 78 | break; 79 | } 80 | } else { 81 | if (val.length > 3) { 82 | try { 83 | if (moment(val, 'YYYY-MM-DD HH:mm').isValid()) { 84 | time = moment(val, 'YYYY-MM-DD HH:mm').valueOf().toString(); 85 | } 86 | } catch (err) { 87 | if (dir === 'from') { 88 | this.isValidFromInput = false; 89 | } else { 90 | this.isValidToInput = false; 91 | } 92 | } 93 | } 94 | } 95 | 96 | if (time !== '') { 97 | if (dir === 'from') { 98 | this.isValidFromInput = true; 99 | this.fromTimeRaw = time; 100 | } else { 101 | this.isValidToInput = true; 102 | this.toTimeRaw = time; 103 | } 104 | } 105 | } 106 | 107 | changeDateCal(event, dir) { 108 | if (dir === 'from') { 109 | this.fromTimeRaw = moment([event.year, event.month - 1, event.day]).valueOf().toString(); 110 | } else { 111 | this.toTimeRaw = moment([event.year, event.month - 1, event.day]).valueOf().toString(); 112 | } 113 | } 114 | 115 | setTimeZone(tz = 'utc') { 116 | this.timezone = tz; 117 | const expireDays = moment().utc().add(7, 'y').toString(); 118 | document.cookie = `timezone=${tz}; expires=${expireDays}; path=/`; 119 | const params: QueryParams = Object.assign({}, this.queryParams); 120 | params.tz = tz; 121 | this.router.navigate([this.path], { queryParams: params, relativeTo: this.route }); 122 | } 123 | 124 | setQuickRange(num: string, unit = 's') { 125 | const params: QueryParams = Object.assign({}, this.queryParams); 126 | params.to = moment().valueOf().toString(); 127 | params.from = moment().subtract(num as moment.unitOfTime.DurationConstructor, unit).valueOf().toString(); 128 | this.router.navigate(['profile'], { queryParams: params }); 129 | } 130 | 131 | setTimeRange(from, to) { 132 | const params: QueryParams = Object.assign({}, this.queryParams); 133 | params.to = this.toTimeRaw; 134 | params.from = this.fromTimeRaw; 135 | this.router.navigate(['profile'], { queryParams: params }); 136 | } 137 | 138 | getDBLogo(distro: string): string { 139 | let src: string; 140 | switch (true) { 141 | case distro.indexOf('Percona Server') !== -1: 142 | src = 'assets/percona-server-black-50.png'; 143 | break; 144 | case distro.indexOf('Percona XtraDB') !== -1: 145 | src = 'assets/Percona_XtraDB_Cluster.png'; 146 | break; 147 | default: 148 | src = 'assets/database.png'; 149 | break; 150 | } 151 | return src; 152 | } 153 | 154 | ngOnDestroy() { 155 | super.ngOnDestroy(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/app/core/page-not-found/page-not-found.component.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
-------------------------------------------------------------------------------- /src/app/core/page-not-found/page-not-found.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/app/core/page-not-found/page-not-found.component.scss -------------------------------------------------------------------------------- /src/app/core/page-not-found/page-not-found.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { DebugElement } from '@angular/core'; 5 | 6 | import { PageNotFoundComponent } from './page-not-found.component'; 7 | 8 | describe('PageNotFoundComponent', () => { 9 | let component: PageNotFoundComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async(() => { 13 | TestBed.configureTestingModule({ 14 | declarations: [ PageNotFoundComponent ] 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(PageNotFoundComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/core/page-not-found/page-not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-page-not-found', 5 | templateUrl: './page-not-found.component.html', 6 | styleUrls: ['./page-not-found.component.scss'] 7 | }) 8 | export class PageNotFoundComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/core/qan-error.handler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler } from '@angular/core'; 2 | 3 | export class QanError { 4 | 5 | public name = 'QanError'; 6 | public message = ''; 7 | 8 | constructor(message: string) { 9 | this.message = message; 10 | } 11 | } 12 | 13 | export class QanErrorHandler extends ErrorHandler { 14 | 15 | handleError(error: any): void { 16 | // console.dir(error); 17 | // console.log(`QanErrorHandler: `, error.rejection.originalError.message); 18 | 19 | try { 20 | super.handleError(error); 21 | } catch (err) { 22 | console.log('hhhh', err); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | demoHosts: ['163.172.51.248', 'pmmdemo.percona.com'], 4 | version: '1.0.0' 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/mongo-query-details/mongo-query-details.component.scss: -------------------------------------------------------------------------------- 1 | .data-output { 2 | position: relative; 3 | overflow-y: scroll; 4 | } 5 | 6 | .pmm-button { 7 | position: fixed; 8 | right: 15px; 9 | } 10 | 11 | section.explain { 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .metrics-table { 17 | .grid-container { 18 | grid-template-columns: 2fr 2fr 3fr 2fr; 19 | 20 | .cell-overlay { 21 | height: 100%; 22 | } 23 | } 24 | 25 | .grid-container:not(:last-child) { 26 | height: 100%; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/mongo-query-details/mongo-query-details.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, ActivatedRoute } from '@angular/router'; 3 | import { Instance, InstanceService } from '../core/instance.service'; 4 | import { CoreComponent, QueryParams } from '../core/core.component'; 5 | import { MongoQueryDetailsService, QueryDetails } from './mongo-query-details.service'; 6 | import * as hljs from 'highlight.js'; 7 | import * as vkbeautify from 'vkbeautify'; 8 | import * as moment from 'moment'; 9 | 10 | @Component({ 11 | moduleId: module.id, 12 | selector: 'app-query-details', 13 | templateUrl: './mongo-query-details.component.html', 14 | styleUrls: ['./mongo-query-details.component.scss'] 15 | }) 16 | export class MongoQueryDetailsComponent extends CoreComponent implements OnInit { 17 | 18 | protected queryID: string; 19 | public queryDetails: any | QueryDetails; 20 | public fingerprint: string; 21 | public queryExample: string; 22 | public classicExplain; 23 | public jsonExplainString; 24 | public jsonExplain; 25 | public errExplain; 26 | protected dbName: string; 27 | public dbTblNames: string; 28 | isCopied = { 29 | queryExample: false, 30 | fingerprint: false, 31 | jsonExplain: false 32 | }; 33 | accordionIds = { 34 | serverSummary: ['metrics-table'], 35 | querySection: ['query-fingerprint'], 36 | explainSection: ['json-explain'], 37 | }; 38 | isSummary: boolean; 39 | isLoading: boolean; 40 | isExplainLoading: boolean; 41 | isFirstSeen: boolean; 42 | firstSeen: string; 43 | lastSeen: string; 44 | event = new Event('showSuccessNotification'); 45 | 46 | constructor(protected route: ActivatedRoute, protected router: Router, 47 | protected instanceService: InstanceService, protected queryDetailsService: MongoQueryDetailsService) { 48 | super(route, router, instanceService); 49 | } 50 | 51 | ngOnInit() { 52 | this.queryParams = this.route.snapshot.queryParams as QueryParams; 53 | this.parseParams(); 54 | this.onChangeParams(this.queryParams); 55 | } 56 | 57 | showSuccessNotification(key) { 58 | this.isCopied[key] = true; 59 | setTimeout( () => { this.isCopied[key] = false }, 3000); 60 | window.parent.document.dispatchEvent(this.event); 61 | } 62 | 63 | onChangeParams(params) { 64 | if (!this.dbServer) { return; } 65 | if (['TOTAL', undefined].indexOf(this.queryParams.queryID) !== -1) { 66 | this.isSummary = true; 67 | this.getServerSummary(this.dbServer.UUID, this.fromUTCDate, this.toUTCDate); 68 | } else { 69 | this.isSummary = false; 70 | this.getQueryDetails(this.dbServer.UUID, this.queryParams.queryID, this.fromUTCDate, this.toUTCDate); 71 | this.accordionIds = { 72 | serverSummary: ['metrics-table'], 73 | querySection: ['query-fingerprint'], 74 | explainSection: ['json-explain'], 75 | }; 76 | } 77 | } 78 | 79 | getQueryDetails(dbServerUUID, queryID, from, to: string) { 80 | this.isLoading = true; 81 | this.dbName = this.dbTblNames = ''; 82 | this.queryExample = ''; 83 | this.queryDetailsService.getQueryDetails(dbServerUUID, queryID, from, to) 84 | .then(data => { 85 | this.queryDetails = data; 86 | this.firstSeen = moment(this.queryDetails.Query.FirstSeen).calendar(null, {sameElse: 'lll'}); 87 | this.lastSeen = moment(this.queryDetails.Query.LastSeen).calendar(null, {sameElse: 'lll'}); 88 | this.fingerprint = this.queryDetails.Query.Fingerprint; 89 | this.queryExample = hljs.highlight('json', vkbeautify.json(this.queryDetails.Example.Query)).value; 90 | this.isFirstSeen = moment.utc(this.queryDetails.Query.FirstSeen).valueOf() > moment.utc(this.fromUTCDate).valueOf(); 91 | this.isLoading = false; 92 | }) 93 | .then(() => this.getExplain()) 94 | .catch(err => console.log(err)); 95 | } 96 | 97 | getServerSummary(dbServerUUID: string, from: string, to: string) { 98 | this.dbName = this.dbTblNames = ''; 99 | this.queryDetailsService.getSummary(dbServerUUID, from, to) 100 | .then(data => { 101 | this.queryDetails = data; 102 | }) 103 | .catch(err => console.log(err)); 104 | } 105 | 106 | async getExplain() { 107 | if (!this.dbServer || !this.dbServer.Agent) { return; } 108 | this.isExplainLoading = true; 109 | this.jsonExplain = ''; 110 | this.errExplain = ''; 111 | const agentUUID = this.dbServer.Agent.UUID; 112 | const dbServerUUID = this.dbServer.UUID; 113 | if (this.dbName === '') { 114 | this.dbName = this.getDBName(); 115 | } 116 | 117 | const query = this.queryDetails.Example.Query; 118 | const data = await this.queryDetailsService.getExplain(agentUUID, dbServerUUID, this.dbName, query); 119 | try { 120 | if (data['Error'] === '') { 121 | const jsonSection = JSON.parse(atob(data['Data'])).JSON; 122 | this.jsonExplain = typeof jsonSection === 'string' ? JSON.parse(jsonSection) : jsonSection; 123 | this.jsonExplainString = JSON.stringify(this.jsonExplain); 124 | } else { 125 | this.errExplain = data['Error'] 126 | } 127 | this.isExplainLoading = false; 128 | } catch (err) { 129 | console.log(err); 130 | this.isExplainLoading = false; 131 | } 132 | } 133 | 134 | getTableName(): string { 135 | if (this.queryDetails.hasOwnProperty('Query') 136 | && this.queryDetails.Query.hasOwnProperty('Tables') 137 | && this.queryDetails.Query.Tables !== null 138 | && this.queryDetails.Query.Tables.length > 0) { 139 | return this.queryDetails.Query.Tables[0].Table; 140 | } 141 | return ''; 142 | } 143 | 144 | private getDBName(): string { 145 | if (this.queryDetails.Example.Db !== '') { 146 | return this.queryDetails.Example.Db; 147 | } else if (this.queryDetails.hasOwnProperty('Query') 148 | && this.queryDetails.Query.hasOwnProperty('Tables') 149 | && this.queryDetails.Query.Tables !== null 150 | && this.queryDetails.Query.Tables.length > 0) { 151 | return this.queryDetails.Query.Tables[0].Db; 152 | } 153 | return ''; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/app/mongo-query-details/mongo-query-details.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async, inject } from '@angular/core/testing'; 4 | import { MongoQueryDetailsService } from './mongo-query-details.service'; 5 | 6 | describe('QueryDetailsService', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | providers: [MongoQueryDetailsService] 10 | }); 11 | }); 12 | 13 | it('should ...', inject([MongoQueryDetailsService], (service: MongoQueryDetailsService) => { 14 | expect(service).toBeTruthy(); 15 | })); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/mongo-query-details/mongo-query-details.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; 3 | 4 | export interface QueryClass { 5 | Id: string; 6 | Abstract: string; 7 | Fingerprint: string; 8 | Tables: Array<{ Db: string, Table: string }> | null; 9 | FirstSeen: string; 10 | LastSeen: string; 11 | Status: string; 12 | }; 13 | 14 | export interface QueryExample { 15 | QueryId: string; 16 | InstanceUUID: string; 17 | Period: string; 18 | Ts: string; 19 | Db: string; 20 | QueryTime: number; 21 | Query: string; 22 | }; 23 | 24 | export interface QueryDetails { 25 | InstanceId: string; 26 | Begin: string; 27 | End: string; 28 | Query: QueryClass; 29 | Example: QueryExample; 30 | Metrics2: {}; 31 | Sparks2: Array<{}>; 32 | }; 33 | 34 | export interface ServerSummary { 35 | InstanceId: string; 36 | Begin: string; 37 | End: string; 38 | Metrics2: {}; 39 | Sparks2: Array<{}>; 40 | }; 41 | 42 | @Injectable() 43 | export class MongoQueryDetailsService { 44 | 45 | private headers = new HttpHeaders({ 'Content-Type': 'application/json' }); 46 | 47 | constructor(private httpClient: HttpClient) { } 48 | 49 | getQueryDetails(dbServerUUID, queryUUID, begin, end: string): Promise { 50 | const url = `/qan-api/qan/report/${dbServerUUID}/query/${queryUUID}`; 51 | 52 | const params = new HttpParams() 53 | .set('begin', begin) 54 | .set('end', end); 55 | 56 | return this.httpClient 57 | .get(url, { headers: this.headers, params: params }) 58 | .toPromise() 59 | .then(response => response as QueryDetails) 60 | .catch(err => console.log(err)); 61 | } 62 | 63 | getSummary(dbServerUUID: string, begin: string, end: string): Promise { 64 | const url = `/qan-api/qan/report/${dbServerUUID}/server-summary`; 65 | 66 | const params = new HttpParams() 67 | .set('begin', begin) 68 | .set('end', end); 69 | 70 | return this.httpClient 71 | .get(url, { headers: this.headers, params: params }) 72 | .toPromise() 73 | .then(response => response as ServerSummary) 74 | .catch(err => console.log(err)); 75 | } 76 | 77 | getTableInfo(agentUUID: string, dbServerUUID: string, dbName: string, tblName: string) { 78 | const url = `/qan-api/agents/${agentUUID}/cmd`; 79 | 80 | const data = { 81 | UUID: dbServerUUID, 82 | Create: [{ 83 | Db: dbName, 84 | Table: tblName 85 | }], 86 | Index: [{ 87 | Db: dbName, 88 | Table: tblName 89 | }], 90 | Status: [{ 91 | Db: dbName, 92 | Table: tblName 93 | }] 94 | }; 95 | 96 | const params = { 97 | AgentUUID: agentUUID, 98 | Service: 'query', 99 | Cmd: 'TableInfo', 100 | Data: btoa(JSON.stringify(data)) 101 | }; 102 | 103 | return this.httpClient 104 | .put(url, params) 105 | .toPromise() 106 | .then(response => JSON.parse(atob(response['Data']))) 107 | .catch(err => console.error(err)); 108 | } 109 | 110 | async getExplain(agentUUID: string, dbServerUUID: string, dbName: string, query: string) { 111 | const url = `/qan-api/agents/${agentUUID}/cmd`; 112 | const data = { 113 | UUID: dbServerUUID, 114 | Db: dbName, 115 | Query: query, 116 | Convert: true // agent will convert if not SELECT and MySQL <= 5.5 or >= 5.6 but no privs 117 | }; 118 | 119 | const params = { 120 | AgentUUID: agentUUID, 121 | Service: 'query', 122 | Cmd: 'Explain', 123 | Data: btoa(JSON.stringify(data)) 124 | }; 125 | 126 | return await this.httpClient 127 | .put(url, params) 128 | .toPromise() 129 | } 130 | 131 | updateTables(queryID: string, dbTables: Array<{}>) { 132 | const url = `/qan-api/queries/${queryID}/tables`; 133 | return this.httpClient 134 | .put(url, dbTables) 135 | .toPromise() 136 | .then(resp => console.log(resp)) 137 | .catch(err => console.error(err)); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/app/mysql-query-details/mysql-query-details.component.scss: -------------------------------------------------------------------------------- 1 | .data-output { 2 | position: relative; 3 | overflow-y: scroll; 4 | } 5 | 6 | .pmm-button { 7 | position: fixed; 8 | right: 15px; 9 | } 10 | 11 | section.explain { 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .query-panel { 17 | margin-bottom: 20px; 18 | } 19 | 20 | .grid-container { 21 | grid-template-columns: 2fr 2fr 3fr 2fr; 22 | } 23 | 24 | .classic-table-wrapper { 25 | display: table; 26 | border-collapse: collapse; 27 | width: 100%; 28 | 29 | .classic-table-row { 30 | display: table-row; 31 | } 32 | 33 | .classic-table-row > div { 34 | display: table-cell; 35 | height: 100%; 36 | padding: 5px !important; 37 | vertical-align: middle; 38 | } 39 | } 40 | 41 | .status-table-wrapper { 42 | .grid-container { 43 | grid-template-columns: repeat(2, 1fr); 44 | } 45 | } 46 | 47 | .indexes-table { 48 | .grid-container-wrapper { 49 | display: flex; 50 | flex-direction: column; 51 | 52 | & > div { 53 | border-right: none !important; 54 | } 55 | } 56 | 57 | .table-body.grid-container, 58 | .table-header.grid-container { 59 | width: 100%; 60 | grid-template-columns: 1fr 1fr 0.5fr 0.5fr 1fr 1fr 1fr 0.5fr 1fr; 61 | } 62 | 63 | .table-body.grid-container > div:not(:last-child) { 64 | border-bottom: none !important; 65 | height: 100%; 66 | display: flex; 67 | align-items: center; 68 | } 69 | } 70 | 71 | .table-wrapper.accordion-table .table-body-row.grid-container { 72 | border-right: none; 73 | } 74 | 75 | .tables-input { 76 | max-width: 420px; 77 | 78 | .form-control { 79 | max-width: 320px; 80 | padding-right: 45px; 81 | } 82 | } 83 | 84 | .table-buttons-wrapper { 85 | margin-bottom: 20px; 86 | 87 | .btn-group { 88 | margin-right: 30px; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/mysql-query-details/mysql-query-details.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async, inject } from '@angular/core/testing'; 4 | import { MySQLQueryDetailsService } from './mysql-query-details.service'; 5 | 6 | describe('QueryDetailsService', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | providers: [MySQLQueryDetailsService] 10 | }); 11 | }); 12 | 13 | it('should ...', inject([MySQLQueryDetailsService], (service: MySQLQueryDetailsService) => { 14 | expect(service).toBeTruthy(); 15 | })); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/mysql-query-details/mysql-query-details.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; 3 | 4 | export interface QueryClass { 5 | Id: string; 6 | Abstract: string; 7 | Fingerprint: string; 8 | Tables: Array<{ Db: string, Table: string }> | null; 9 | FirstSeen: string; 10 | LastSeen: string; 11 | Status: string; 12 | }; 13 | 14 | export interface QueryExample { 15 | QueryId: string; 16 | InstanceUUID: string; 17 | Period: string; 18 | Ts: string; 19 | Db: string; 20 | QueryTime: number; 21 | Query: string; 22 | }; 23 | 24 | export interface QueryDetails { 25 | InstanceId: string; 26 | Begin: string; 27 | End: string; 28 | Query: QueryClass; 29 | Example: QueryExample; 30 | Metrics2: {}; 31 | Sparks2: Array<{}>; 32 | }; 33 | 34 | export interface ServerSummary { 35 | InstanceId: string; 36 | Begin: string; 37 | End: string; 38 | Metrics2: {}; 39 | Sparks2: Array<{}>; 40 | }; 41 | 42 | @Injectable() 43 | export class MySQLQueryDetailsService { 44 | 45 | private headers = new HttpHeaders({'Content-Type': 'application/json'}); 46 | 47 | constructor(private httpClient: HttpClient) { 48 | } 49 | 50 | public async getQueryDetails(dbServerUUID, queryUUID, begin, end: string): Promise { 51 | const url = `/qan-api/qan/report/${dbServerUUID}/query/${queryUUID}`; 52 | const params = new HttpParams() 53 | .set('begin', begin) 54 | .set('end', end); 55 | 56 | const response = await this.httpClient 57 | .get(url, {headers: this.headers, params: params}) 58 | .toPromise(); 59 | return response as QueryDetails; 60 | } 61 | 62 | public async getSummary(dbServerUUID: string, begin: string, end: string): Promise { 63 | const url = `/qan-api/qan/report/${dbServerUUID}/server-summary`; 64 | 65 | const params = new HttpParams() 66 | .set('begin', begin) 67 | .set('end', end); 68 | 69 | const response = await this.httpClient 70 | .get(url, {headers: this.headers, params: params}) 71 | .toPromise(); 72 | return response as ServerSummary; 73 | } 74 | 75 | getTableInfo(agentUUID: string, dbServerUUID: string, dbName: string, tblName: string) { 76 | const url = `/qan-api/agents/${agentUUID}/cmd`; 77 | 78 | const data = { 79 | UUID: dbServerUUID, 80 | Create: [{ 81 | Db: dbName, 82 | Table: tblName 83 | }], 84 | Index: [{ 85 | Db: dbName, 86 | Table: tblName 87 | }], 88 | Status: [{ 89 | Db: dbName, 90 | Table: tblName 91 | }] 92 | }; 93 | 94 | const params = { 95 | AgentUUID: agentUUID, 96 | Service: 'query', 97 | Cmd: 'TableInfo', 98 | Data: btoa(JSON.stringify(data)) 99 | }; 100 | 101 | return this.httpClient 102 | .put(url, params) 103 | .toPromise() 104 | .then(response => JSON.parse(atob(response['Data']))); 105 | } 106 | 107 | getExplain(agentUUID: string, dbServerUUID: string, dbName: string, query: string) { 108 | const url = `/qan-api/agents/${agentUUID}/cmd`; 109 | const data = { 110 | UUID: dbServerUUID, 111 | Db: dbName, 112 | Query: query, 113 | Convert: true // agent will convert if not SELECT and MySQL <= 5.5 or >= 5.6 but no privs 114 | }; 115 | 116 | const params = { 117 | AgentUUID: agentUUID, 118 | Service: 'query', 119 | Cmd: 'Explain', 120 | Data: btoa(JSON.stringify(data)) 121 | }; 122 | 123 | return this.httpClient 124 | .put(url, params) 125 | .toPromise() 126 | .then(response => response); 127 | } 128 | 129 | updateTables(queryID: string, dbTables: Array<{}>) { 130 | const url = `/qan-api/queries/${queryID}/tables`; 131 | return this.httpClient 132 | .put(url, dbTables) 133 | .toPromise() 134 | .then(resp => console.log(resp)); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/app/query-profile/query-profile.component.scss: -------------------------------------------------------------------------------- 1 | .table-header { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | align-items: center; 6 | margin-bottom: 15px; 7 | 8 | .table-title { 9 | max-width: 440px; 10 | width: 100%; 11 | font-size: 18px; 12 | } 13 | 14 | .display-options { 15 | display: flex; 16 | justify-content: space-between; 17 | align-items: center; 18 | 19 | .option-container { 20 | height: 40px; 21 | display: flex; 22 | align-items: center; 23 | overflow: hidden; 24 | margin-left: 10px; 25 | box-sizing: border-box; 26 | } 27 | } 28 | } 29 | 30 | .table-body { 31 | margin-bottom: 15px; 32 | display: flex; 33 | flex-direction: column; 34 | box-sizing: border-box; 35 | 36 | .grid-container { 37 | display: grid; 38 | grid-template-columns: 1fr 9fr 7fr 10fr 9fr; 39 | grid-template-rows: 40px; 40 | box-sizing: border-box; 41 | outline: none; 42 | 43 | &:active, 44 | &:visited, 45 | &:hover { 46 | outline: none; 47 | } 48 | 49 | &:first-child { 50 | font-weight: 500; 51 | font-size: 12px; 52 | } 53 | 54 | &.table-selected > div:first-child, 55 | &.first-seen-query > div:first-child{ 56 | padding-left: 8px !important; 57 | border-left: 3px solid #2e5785 !important; 58 | } 59 | 60 | &.table-selected { 61 | outline: none; 62 | 63 | &:active, 64 | &:visited, 65 | &:hover { 66 | outline: none 67 | } 68 | } 69 | 70 | > div { 71 | border-left: none; 72 | border-bottom: none; 73 | box-sizing: border-box; 74 | display: flex; 75 | align-items: center; 76 | justify-content: space-between; 77 | padding: 0 10px; 78 | 79 | &:first-child { 80 | padding-right: 0; 81 | border-left: 1px solid; 82 | } 83 | } 84 | } 85 | } 86 | 87 | .load-more-wrapper { 88 | display: flex; 89 | justify-content: center; 90 | align-items: center; 91 | margin-bottom: 20px; 92 | } 93 | 94 | .main-table-overlay { 95 | display: block; 96 | width: 30%; 97 | height: 30px; 98 | } 99 | 100 | .total-queries-amount { 101 | max-width: 350px; 102 | height: 40px; 103 | width: auto; 104 | padding: 0 15px; 105 | cursor: pointer; 106 | } 107 | 108 | input:disabled { 109 | cursor: not-allowed !important; 110 | opacity: .65; 111 | z-index: 100; 112 | } 113 | -------------------------------------------------------------------------------- /src/app/query-profile/query-profile.component.ts: -------------------------------------------------------------------------------- 1 | import { CoreComponent, QueryParams, QanError } from '../core/core.component'; 2 | import { Component } from '@angular/core'; 3 | import { InstanceService } from '../core/instance.service'; 4 | import { QueryProfileService } from './query-profile.service'; 5 | import { Router, ActivatedRoute } from '@angular/router'; 6 | import * as moment from 'moment'; 7 | import { MomentFormatPipe } from '../shared/moment-format.pipe'; 8 | 9 | const queryProfileError = 'No data. Please check pmm-client and database configurations on selected instance.'; 10 | 11 | @Component({ 12 | moduleId: module.id, 13 | templateUrl: 'query-profile.component.html', 14 | styleUrls: ['./query-profile.component.scss'], 15 | }) 16 | export class QueryProfileComponent extends CoreComponent { 17 | 18 | public queryProfile: Array<{}>; 19 | public profileTotal; 20 | public offset: number; 21 | public totalAmountOfQueries: number; 22 | public leftInDbQueries: number; 23 | public searchValue: string; 24 | public fromDate: string; 25 | public toDate: string; 26 | public isLoading: boolean; 27 | public isQuerySwitching: boolean; 28 | public noQueryError: string; 29 | public isFirstSeen: boolean; 30 | public isFirsSeenChecked = false; 31 | public isSearchQuery = false; 32 | 33 | constructor( 34 | protected route: ActivatedRoute, 35 | protected router: Router, 36 | protected instanceService: InstanceService, 37 | protected queryProfileService: QueryProfileService 38 | ) { 39 | super(route, router, instanceService); 40 | } 41 | 42 | onChangeParams(params) { 43 | // checks changing tz 44 | const momentFormatPipe = new MomentFormatPipe(); 45 | this.fromDate = momentFormatPipe.transform(this.from, 'llll'); 46 | this.toDate = momentFormatPipe.transform(this.to, 'llll'); 47 | // only if host, from and to are diffrent from prev router - load queries. 48 | if (!this.previousQueryParams || 49 | this.previousQueryParams['var-host'] !== this.queryParams['var-host'] || 50 | this.previousQueryParams.from !== this.queryParams.from || 51 | this.previousQueryParams.to !== this.queryParams.to || 52 | this.previousQueryParams.search !== this.queryParams.search || 53 | this.previousQueryParams.first_seen !== this.queryParams.first_seen || 54 | this.previousQueryParams.tz !== this.queryParams.tz) { 55 | this.loadQueries(); 56 | } 57 | } 58 | 59 | checkFirstSeen(currentQuery) { 60 | this.isFirstSeen = moment.utc(currentQuery['FirstSeen']).valueOf() > moment.utc(this.fromUTCDate).valueOf(); 61 | return this.isFirstSeen; 62 | } 63 | 64 | public async loadQueries() { 65 | this.dbServer = this.instanceService.dbServers[0]; 66 | for (const dbServer of this.instanceService.dbServers) { 67 | if (dbServer.Name === this.queryParams['var-host']) { 68 | this.dbServer = dbServer; 69 | } 70 | } 71 | this.isQuerySwitching = true; 72 | 73 | // clear after error 74 | this.noQueryError = ''; 75 | this.totalAmountOfQueries = this.leftInDbQueries = 0; 76 | this.queryProfile = []; 77 | this.searchValue = this.queryParams.search === 'null' ? '' : this.queryParams.search; 78 | const search = this.queryParams.search === 'null' && this.searchValue !== 'NULL' ? '' : this.queryParams.search; 79 | const firstSeen = this.queryParams.first_seen; 80 | this.offset = 0; 81 | try { 82 | const data = await this.queryProfileService 83 | .getQueryProfile(this.dbServer.UUID, this.fromUTCDate, this.toUTCDate, this.offset, search, firstSeen); 84 | if (data.hasOwnProperty('Error') && data['Error'] !== '') { 85 | throw new QanError('Queries are not available.'); 86 | } 87 | this.totalAmountOfQueries = data['TotalQueries']; 88 | if (this.totalAmountOfQueries > 0) { 89 | this.queryProfile = data['Query']; 90 | this.leftInDbQueries = this.totalAmountOfQueries - (this.queryProfile.length - 1); 91 | this.profileTotal = this.queryProfile[0]; 92 | } 93 | } catch (err) { 94 | console.error(err); 95 | this.noQueryError = err.name === QanError.errType ? err.message : queryProfileError; 96 | } finally { 97 | this.isQuerySwitching = false; 98 | } 99 | } 100 | 101 | public async loadMoreQueries() { 102 | this.isLoading = true; 103 | const dbServerUUID = this.dbServer.UUID; 104 | this.offset = this.offset + 10; 105 | const search = 106 | this.queryParams.search === 'null' && 107 | this.searchValue !== 'NULL' && this.searchValue !== 'null' ? '' : this.queryParams.search; 108 | const firstSeen = this.queryParams.first_seen; 109 | const data = await this.queryProfileService 110 | .getQueryProfile(dbServerUUID, this.fromUTCDate, this.toUTCDate, this.offset, search, firstSeen); 111 | 112 | const _ = data['Query'].shift(); 113 | for (const q of data['Query']) { 114 | this.queryProfile.push(q); 115 | } 116 | this.leftInDbQueries = this.totalAmountOfQueries - (this.queryProfile.length - 1); 117 | this.isLoading = false; 118 | } 119 | 120 | composeQueryParamsForGrid(queryID: string | null): QueryParams { 121 | const queryParams: QueryParams = Object.assign({}, this.queryParams); 122 | queryParams.queryID = queryID || 'TOTAL'; 123 | return queryParams; 124 | } 125 | 126 | search() { 127 | this.isSearchQuery = true; 128 | const params: QueryParams = Object.assign({}, this.queryParams); 129 | if (!!this.searchValue) { 130 | params.search = this.searchValue === 'null' ? 'NULL' : this.searchValue; 131 | } else { 132 | delete params.search; 133 | } 134 | delete params.queryID; 135 | this.router.navigate(['profile'], { queryParams: params }); 136 | } 137 | 138 | getFirstSeen(isFirsSeenChecked = false) { 139 | this.isQuerySwitching = true; 140 | this.isFirsSeenChecked = isFirsSeenChecked; 141 | const params: QueryParams = Object.assign({}, this.queryParams); 142 | if (isFirsSeenChecked) { 143 | params.first_seen = this.isFirsSeenChecked; 144 | } else { 145 | delete params.first_seen; 146 | } 147 | delete params.queryID; 148 | this.router.navigate(['profile'], { queryParams: params }); 149 | this.isQuerySwitching = false; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/app/query-profile/query-profile.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async, inject } from '@angular/core/testing'; 4 | import { QueryProfileService } from './query-profile.service'; 5 | 6 | describe('QueryProfileService', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | providers: [QueryProfileService] 10 | }); 11 | }); 12 | 13 | it('should ...', inject([QueryProfileService], (service: QueryProfileService) => { 14 | expect(service).toBeTruthy(); 15 | })); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/query-profile/query-profile.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; 3 | 4 | @Injectable() 5 | export class QueryProfileService { 6 | 7 | private headers = new HttpHeaders({'Content-Type': 'application/json'}); 8 | 9 | constructor(private httpClient: HttpClient) { 10 | } 11 | 12 | public async getQueryProfile(dbServerUUID, begin, end: string, 13 | offset = 0, search = '', first_seen): Promise<{}> { 14 | const url = `/qan-api/qan/profile/${dbServerUUID}`; 15 | const searchValue = btoa( 16 | search.replace(/%([0-9A-F]{2})/g, 17 | (match, p1) => String.fromCharCode(Number('0x' + p1))) 18 | ); 19 | const params = new HttpParams() 20 | .set('begin', begin) 21 | .set('end', end) 22 | .set('offset', String(offset)) 23 | .set('first_seen', String(!!first_seen)) 24 | .set('search', searchValue); 25 | 26 | return await this.httpClient 27 | .get(url, {headers: this.headers, params: params}) 28 | .toPromise(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/remote-instances-list/remote-instances-list.component.html: -------------------------------------------------------------------------------- 1 |

Amazon RDS and remote instances

2 |
3 |
4 | Name 5 | Endpoint 6 |
7 | Region 8 | 9 | 10 |
11 |
12 | Engine 13 | 14 | 15 |
16 | Remove 17 |
18 |
19 | {{ instance.node.name }} 20 | {{ instance.service.address }}:{{ instance.service.port }} 21 | {{ instance.node.region }} 22 | {{ instance.service.engine }} {{ instance.service.engine_version }} 23 | 24 | 25 | 26 |
27 |
28 |
29 |
30 | 31 |
32 |
33 | 37 | -------------------------------------------------------------------------------- /src/app/remote-instances-list/remote-instances-list.component.scss: -------------------------------------------------------------------------------- 1 | h3 { 2 | margin-bottom: 1.5rem; 3 | } 4 | 5 | .grid-table { 6 | display: grid; 7 | grid-template-rows: minmax(25px, auto); 8 | grid-auto-rows: minmax(40px, auto); 9 | border: 1px solid #292929; 10 | } 11 | 12 | .grid-table > *:not(:last-child) { 13 | border-bottom: 1px solid #292929; 14 | } 15 | 16 | .grid-table__row > *:last-child { 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | .grid-table__row { 23 | display: grid; 24 | grid-template-columns: 2.5fr 6fr 1.5fr 2fr 80px; 25 | } 26 | 27 | .grid-table__row > * { 28 | display: flex; 29 | align-items: center; 30 | padding: 0 10px; 31 | word-break: break-all; 32 | } 33 | 34 | .grid-table__row > *:not(.row__cell--highlight) { 35 | border-right: 1px solid #292929; 36 | } 37 | 38 | .grid-table__row > *:last-child { 39 | border-right: none; 40 | } 41 | 42 | .row__cell--highlight { 43 | border: 1px solid white; 44 | } 45 | 46 | .fa.fa-trash-o { 47 | cursor: pointer; 48 | color: #444444; 49 | font-size: 1.2rem; 50 | } 51 | 52 | .pointer { 53 | cursor: pointer; 54 | } 55 | 56 | .sort-icon { 57 | margin-left: 10px; 58 | } 59 | -------------------------------------------------------------------------------- /src/app/remote-instances-list/remote-instances-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { RemoteInstancesListComponent } from './remote-instances-list.component'; 4 | 5 | describe('RemoteInstancesListComponent', () => { 6 | let component: RemoteInstancesListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ RemoteInstancesListComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(RemoteInstancesListComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/remote-instances-list/remote-instances-list.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit} from '@angular/core'; 2 | import {RemoteInstancesListService} from './remote-instances-list.service'; 3 | import {RemoteInstance, RemoteInstanceNode, RemoteInstanceService} from '../add-remote-instances/add-remote-instance.service'; 4 | import {environment} from '../environment'; 5 | import {AddAmazonRDSService} from '../add-amazon-rds/add-amazon-rds.service'; 6 | 7 | @Component({ 8 | selector: 'app-remote-instances-list', 9 | templateUrl: './remote-instances-list.component.html', 10 | styleUrls: ['./remote-instances-list.component.scss'] 11 | }) 12 | export class RemoteInstancesListComponent implements OnInit { 13 | public allInstances: RemoteInstance[] = []; 14 | public path: string[] = ['instance']; // same variable as for the loop that generates the table rows 15 | order = 1; 16 | isSorted = false; 17 | isRegion = false; 18 | isDemo = false; 19 | isLoading: boolean; 20 | errorMessage: string; 21 | 22 | constructor(private remoteInstancesListService: RemoteInstancesListService, private awsService: AddAmazonRDSService) { 23 | this.isDemo = environment.demoHosts.includes(location.hostname); 24 | } 25 | 26 | async ngOnInit() { 27 | this.errorMessage = ''; 28 | this.isLoading = true; 29 | try { 30 | this.allInstances = await this.remoteInstancesListService.getList(); 31 | } catch (err) { 32 | this.errorMessage = err.json().error; 33 | this.isLoading = false; 34 | return; 35 | } 36 | this.errorMessage = this.allInstances === undefined ? 'The list of instances is empty' : ''; 37 | this.isLoading = false; 38 | } 39 | 40 | async disableInstance(node: RemoteInstanceNode, service: RemoteInstanceService) { 41 | if (this.isDemo) { 42 | return false; 43 | } 44 | this.isLoading = false; 45 | const text = `Are you sure you want to delete? ${node.name}`; 46 | if (confirm(text)) { 47 | try { 48 | if (service.type !== 'rds') { 49 | const res = await this.remoteInstancesListService.disable(node, service); 50 | } else { 51 | const res = await this.awsService.disable(node); 52 | } 53 | this.allInstances = await this.remoteInstancesListService.getList(); 54 | } catch (err) { 55 | this.errorMessage = err.json().error; 56 | this.isLoading = false; 57 | return; 58 | } 59 | this.errorMessage = this.allInstances === undefined ? 'The list of instances is empty' : ''; 60 | this.isLoading = false; 61 | } 62 | } 63 | 64 | sortInstances(prop: string) { 65 | this.path = prop.split('.'); 66 | this.order = this.order * (-1); // change order 67 | this.isSorted = true; 68 | this.isRegion = prop === 'node.region'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/remote-instances-list/remote-instances-list.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { RemoteInstancesListService } from './remote-instances-list.service'; 4 | 5 | describe('RemoteInstancesListService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [RemoteInstancesListService] 9 | }); 10 | }); 11 | 12 | it('should be created', inject([RemoteInstancesListService], (service: RemoteInstancesListService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/remote-instances-list/remote-instances-list.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {RemoteInstance, RemoteInstanceNode, RemoteInstanceService} from '../add-remote-instances/add-remote-instance.service'; 3 | import {HttpClient, HttpHeaders} from '@angular/common/http'; 4 | 5 | @Injectable() 6 | export class RemoteInstancesListService { 7 | 8 | private headers = new HttpHeaders({ 'Content-Type': 'application/json' }); 9 | 10 | constructor(private http: HttpClient) { 11 | } 12 | 13 | async getList(): Promise { 14 | const url = `/managed/v0/remote`; 15 | const response = await this.http 16 | .get(url, { headers: this.headers }) 17 | .toPromise(); 18 | return response['instances'] as RemoteInstance[]; 19 | } 20 | 21 | async disable(node: RemoteInstanceNode, service: RemoteInstanceService): Promise<{}> { 22 | const url = `/managed/v0/${service.type}/${node.id}`; 23 | return await this.http 24 | .delete(url, {headers: this.headers}) 25 | .toPromise(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/settings/settings.component.scss: -------------------------------------------------------------------------------- 1 | p { 2 | margin-bottom: 0; 3 | width: 100%; 4 | } 5 | #settings_apply_button { 6 | cursor: pointer; 7 | } 8 | 9 | .accordion-head-second button { 10 | cursor: pointer; 11 | } 12 | 13 | .button-block { 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | .button-block .success-message, 19 | .button-block .error-message { 20 | margin-bottom: 0; 21 | margin-left: 15px; 22 | opacity: 0; 23 | transition: .5s ease-in-out all; 24 | } 25 | 26 | .button-block .success-message { 27 | color: green; 28 | } 29 | 30 | .button-block .error-message { 31 | color: red; 32 | } 33 | 34 | .show { 35 | opacity: 1 !important; 36 | } 37 | 38 | .accordion-head-second.text-primary { 39 | display: flex; 40 | align-items: baseline; 41 | } 42 | 43 | #refreshStatusLink, 44 | #refreshLogLink { 45 | color: inherit; 46 | margin-left: 5px; 47 | } 48 | 49 | #update-group { 50 | margin: 0 5px; 51 | } 52 | 53 | .pl-20 { 54 | padding-left: 20px; 55 | } 56 | 57 | .button-block { 58 | height: 40px; 59 | } 60 | 61 | .pmm-button { 62 | width: 95px; 63 | opacity: 1 !important; 64 | 65 | &:disabled { 66 | cursor: not-allowed !important; 67 | opacity: .65; 68 | } 69 | } 70 | 71 | .btn-copied { 72 | opacity: 1 !important; 73 | } 74 | -------------------------------------------------------------------------------- /src/app/settings/settings.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { DebugElement } from '@angular/core'; 5 | 6 | import { SettingsComponent } from './settings.component'; 7 | 8 | describe('SettingsComponent', () => { 9 | let component: SettingsComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async(() => { 13 | TestBed.configureTestingModule({ 14 | declarations: [ SettingsComponent ] 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(SettingsComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {Router, ActivatedRoute} from '@angular/router'; 3 | import {InstanceService} from '../core/instance.service'; 4 | import {CoreComponent} from '../core/core.component'; 5 | import {environment} from '../environment'; 6 | import * as moment from 'moment'; 7 | import {SettingsService} from './settings.service'; 8 | import {interval, Observable} from 'rxjs'; 9 | import {map} from 'rxjs/operators'; 10 | 11 | @Component({ 12 | moduleId: module.id, 13 | selector: 'app-settings', 14 | templateUrl: './settings.component.html', 15 | styleUrls: ['./settings.component.scss'], 16 | providers: [SettingsService], 17 | }) 18 | export class SettingsComponent extends CoreComponent { 19 | public agentStatus: {}; 20 | public qanConf: {}; 21 | public agentConf: any; 22 | public oldInterval = '1'; 23 | public interval = '1'; 24 | public collectFrom: 'perfschema' | 'slowlog' = 'slowlog'; 25 | public exampleQueries: boolean; 26 | public statusUpdatedFromNow$: Observable; 27 | public logUpdatedFromNow$: Observable; 28 | public agentLog: {}; 29 | public severityLeveles: Array = [ 30 | 'emerg', 'alert', 'crit', 'err', 31 | 'warning', 'notice', 'info', 'debug' 32 | ]; 33 | 34 | public logPeriod = 12; 35 | public isDemo = false; 36 | isSuccess = false; 37 | isError = false; 38 | 39 | constructor(protected route: ActivatedRoute, protected router: Router, 40 | protected settingsService: SettingsService, 41 | protected instanceService: InstanceService) { 42 | super(route, router, instanceService); 43 | this.isDemo = environment.demoHosts.includes(location.hostname); 44 | } 45 | 46 | 47 | /** 48 | * Get agent log for N last hours. 49 | * @param period time period integer in hours 50 | */ 51 | setLogPeriod(period): void { 52 | this.logPeriod = period; 53 | this.getAgentLog(); 54 | } 55 | 56 | /** 57 | * get fresh agent log 58 | */ 59 | refreshAgentLog(): void { 60 | this.getAgentLog(); 61 | } 62 | 63 | validateValue(value) { 64 | if (this.interval === null) { return this.interval = ''; } 65 | 66 | if (this.oldInterval !== value) { 67 | this.interval = (value > 60 || value < 1) ? this.oldInterval : value; 68 | } 69 | this.oldInterval = this.interval; 70 | } 71 | 72 | /** 73 | * Get from agent: 74 | * - Collect interval: positive intager; 75 | * - Send real query examples: boolean; 76 | * - Collect from: 'slowlog' or 'perfschema'. 77 | */ 78 | public async getAgentDefaults() { 79 | const res = await this.settingsService.getAgentDefaults(this.agent.UUID, this.dbServer.UUID); 80 | try { 81 | this.agentConf = res; 82 | this.interval = (this.agentConf.qan.Interval / 60).toString(); 83 | this.collectFrom = this.agentConf.qan.CollectFrom; 84 | this.exampleQueries = this.agentConf.qan.ExampleQueries; 85 | } catch (err) { 86 | console.error(err) 87 | } 88 | } 89 | 90 | /** 91 | * Set on agent: 92 | * - Collect interval: positive intager; 93 | * - Send real query examples: boolean; 94 | * - Collect from: 'slowlog' or 'perfschema'. 95 | */ 96 | public async setAgentDefaults() { 97 | const res = await this.settingsService.setAgentDefaults( 98 | this.agent.UUID, 99 | this.dbServer.UUID, 100 | +this.interval, 101 | this.exampleQueries, 102 | this.collectFrom 103 | ); 104 | const visibleMessageTime = 5000; 105 | try { 106 | // this.agentConf = res; // diffrent responce than GetDefaults. 107 | this.isSuccess = true; 108 | this.isError = false; 109 | setTimeout(() => { 110 | this.isSuccess = false; 111 | this.isError = false; 112 | }, visibleMessageTime); // add const 113 | this.getAgentDefaults(); 114 | } catch (err) { 115 | this.isSuccess = false; 116 | this.isError = true; 117 | setTimeout(() => { 118 | this.isSuccess = false; 119 | this.isError = false; 120 | }, visibleMessageTime); 121 | console.error(err); 122 | } 123 | } 124 | 125 | /** 126 | * Get slice of exported variables of agent. 127 | */ 128 | getAgentStatus() { 129 | this.agentStatus = this.settingsService.getAgentStatus(this.agent.UUID); 130 | const updated: any = moment(); 131 | this.statusUpdatedFromNow$ = interval(60000).pipe(map(n => updated.fromNow())); 132 | } 133 | 134 | /** 135 | * get agent log for some period. 136 | */ 137 | getAgentLog() { 138 | const begin = moment.utc().subtract(this.logPeriod, 'h').format('YYYY-MM-DDTHH:mm:ss'); 139 | const end = moment.utc().format('YYYY-MM-DDTHH:mm:ss'); 140 | this.agentLog = this.settingsService.getAgentLog(this.agent.UUID, begin, end); 141 | const updated: any = moment(); 142 | this.logUpdatedFromNow$ = interval(60000).pipe(map(n => updated.fromNow())); 143 | } 144 | 145 | /** 146 | * Ovverrides parent method. 147 | * Executes on route was changed to refresh data. 148 | * @param params - URL query parameters 149 | */ 150 | onChangeParams(params) { 151 | this.getAgentDefaults(); 152 | this.getAgentStatus(); 153 | this.getAgentLog(); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/app/settings/settings.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async, inject } from '@angular/core/testing'; 4 | import { SettingsService } from './settings.service'; 5 | 6 | describe('SettingsService', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | providers: [SettingsService] 10 | }); 11 | }); 12 | 13 | it('should ...', inject([SettingsService], (service: SettingsService) => { 14 | expect(service).toBeTruthy(); 15 | })); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/settings/settings.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; 3 | 4 | export type CollectFrom = 'slowlog' | 'perfschema'; 5 | 6 | @Injectable() 7 | export class SettingsService { 8 | 9 | private headers = new HttpHeaders({ 'Content-Type': 'application/json' }); 10 | 11 | constructor(private httpClient: HttpClient) { } 12 | 13 | public async getAgentStatus(agentUUID: string): Promise<{}> { 14 | const url = `/qan-api/agents/${agentUUID}/status`; 15 | 16 | const response = await this.httpClient 17 | .get(url, { headers: this.headers }) 18 | .toPromise(); 19 | return response as {}; 20 | } 21 | 22 | public async getAgentLog(agentUUID, begin, end: string): Promise<{}> { 23 | const url = `/qan-api/agents/${agentUUID}/log`; 24 | 25 | const params = new HttpParams() 26 | .set('begin', begin) 27 | .set('end', end); 28 | const response = await this.httpClient 29 | .get(url, { headers: this.headers, params: params }) 30 | .toPromise(); 31 | return response as {}; 32 | } 33 | 34 | public async getAgentDefaults(agentUUID: string, dbServerUUID: string): Promise<{}> { 35 | const url = `/qan-api/agents/${agentUUID}/cmd`; 36 | const params = { 37 | AgentUUID: agentUUID, 38 | Service: 'agent', 39 | Cmd: 'GetDefaults', 40 | Data: btoa(JSON.stringify({ UUID: dbServerUUID })) 41 | }; 42 | 43 | const resp = await this.httpClient 44 | .put(url, params, {headers: this.headers}) 45 | .toPromise(); 46 | if (!!resp['Error']) { 47 | throw new Error(resp['Error']); 48 | } else { 49 | return JSON.parse(atob(resp['Data'])); 50 | } 51 | } 52 | 53 | public async setAgentDefaults(agentUUID: string, dbServerUUID: string, interval: number, 54 | exampleQueries: boolean, collectFrom: CollectFrom): Promise<{}> { 55 | const url = `/qan-api/agents/${agentUUID}/cmd`; 56 | 57 | const data = { 58 | UUID: dbServerUUID, 59 | Interval: interval * 60, 60 | ExampleQueries: exampleQueries, 61 | CollectFrom: collectFrom 62 | }; 63 | 64 | const params = { 65 | AgentUUID: agentUUID, 66 | Service: 'qan', 67 | Cmd: 'RestartTool', 68 | Data: btoa(JSON.stringify(data)) 69 | }; 70 | 71 | const resp = await this.httpClient 72 | .put(url, params, {headers: this.headers}) 73 | .toPromise(); 74 | if (!!resp['Error']) { 75 | throw new Error(resp['Error']); 76 | } else { 77 | return JSON.parse(atob(resp['Data'])); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/app/shared/humanize.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { HumanizePipe } from './humanize.pipe'; 5 | 6 | describe('HumanizePipe', () => { 7 | it('create an instance', () => { 8 | const pipe = new HumanizePipe(); 9 | expect(pipe).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/shared/humanize.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import * as moment from 'moment'; 3 | import * as numeral from 'numeral'; 4 | 5 | /** 6 | * @desc humanize time duration 7 | * @example
{{ duration | humanize }}
8 | */ 9 | @Pipe({ name: 'humanize' }) 10 | export class HumanizePipe implements PipeTransform { 11 | 12 | parceTime(input: number) { 13 | 14 | let dur = ''; 15 | const dur_sec = moment.duration(input, 's'); 16 | switch (true) { 17 | case input === 0: 18 | dur = '0'; 19 | break; 20 | case dur_sec.as('s') > 1 && dur_sec.as('s') < 60: 21 | dur = dur_sec.as('s').toFixed(2) + ' sec'; 22 | break; 23 | case dur_sec.as('s') >= 60: 24 | let secs = dur_sec.as('s'); 25 | const secondsInDay = 24 * 60 * 60; 26 | if (secs >= secondsInDay) { 27 | const days = Math.floor(secs / secondsInDay); 28 | dur = `${days} days, `; 29 | secs = secs % secondsInDay; 30 | } 31 | dur += numeral(secs).format('00:00:00'); 32 | break; 33 | case dur_sec.as('ms') < 1: 34 | dur = (dur_sec.as('ms') * 1000).toFixed(2) + ' \µs'; 35 | break; 36 | default: 37 | dur = dur_sec.as('ms').toFixed(2) + ' ms'; 38 | break; 39 | } 40 | return dur; 41 | } 42 | 43 | transform(input: number, name: string): string { 44 | 45 | if (input === null) { 46 | return '0'; 47 | } 48 | 49 | let res = '0'; 50 | switch (true) { 51 | // "top 10"/profile queries no name parameters 52 | case name === undefined: 53 | res = this.parceTime(input); 54 | break; 55 | // time 56 | case name.indexOf('time') > -1: 57 | res = (input !== 0 && input < 0.00001) ? '<' : ''; 58 | res += this.parceTime(input); 59 | break; 60 | // size 61 | case name.indexOf('size') > -1: 62 | if (input !== 0 && input < 0.01) { 63 | res = '<0.01 B'; 64 | } else { 65 | res = numeral(input).format('0.00 b'); 66 | } 67 | res = res.replace(/([\d]) B/, '$1 Bytes'); 68 | break; 69 | // ops 70 | case name.indexOf('number') > -1: 71 | if (input !== 0 && input < 0.01) { 72 | res = '<0.01'; 73 | } else { 74 | res = numeral(input).format('0.00a'); 75 | } 76 | break; 77 | case name.indexOf('percent') > -1: 78 | if (input !== 0 && input < 0.0001) { 79 | res = '<0.01'; 80 | } else { 81 | res = numeral(input).format('0.00%'); 82 | } 83 | break; 84 | // ops 85 | default: 86 | if (input !== 0 && input < 0.01) { 87 | res = '<0.01'; 88 | } else { 89 | res = numeral(input).format('0.00 a'); 90 | } 91 | break; 92 | } 93 | return String(res).replace('<0.00', '<0.01'); 94 | 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/app/shared/latency-chart.directive.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { ElementRef } from '@angular/core'; 5 | import { LatencyChartDirective } from './latency-chart.directive'; 6 | 7 | describe('LatencyChartDirective', () => { 8 | it('should create an instance', () => { 9 | const elementRef = new ElementRef('
'); 10 | const directive = new LatencyChartDirective(elementRef); 11 | expect(directive).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/shared/latency-chart.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input, HostBinding } from '@angular/core'; 2 | import { ElementRef } from '@angular/core'; 3 | 4 | import { select } from 'd3-selection'; 5 | import { scaleLog } from 'd3-scale'; 6 | 7 | import { HumanizePipe } from './humanize.pipe'; 8 | 9 | @Directive({ 10 | selector: '[appLatencyChart]' 11 | }) 12 | export class LatencyChartDirective { 13 | 14 | @HostBinding('attr.data-tooltip') 15 | @Input() dataTooltip: string; 16 | @Input() measurement = 'time'; 17 | @Input() metricPrefix = ''; 18 | 19 | @Input() set appLatencyChart(data: {}) { 20 | if (data !== null) { 21 | this.drawChart(data); 22 | } 23 | } 24 | 25 | constructor( 26 | public elementRef: ElementRef, 27 | ) { } 28 | 29 | drawChart(data: {}) { 30 | const chart = select(this.elementRef.nativeElement); 31 | chart.selectAll('*').remove(); 32 | const svg: any = chart.append('svg') 33 | .attr('height', '20') 34 | .attr('width', '100') 35 | .attr('class', 'scaling-svg') 36 | .attr('viewBox', '0 0 100 20'); 37 | 38 | const width = Math.floor(svg.node().getBoundingClientRect().width); 39 | svg.attr('width', width).attr('viewBox', '0 0 ' + width + ' 20'); 40 | 41 | const x = scaleLog() 42 | .domain([0.00001, 10000]) 43 | .range([2, width - 2]) 44 | .clamp(true) 45 | .nice(); 46 | 47 | let min = 0; 48 | let max = 0; 49 | let avg = 0; 50 | let p95 = 0; 51 | 52 | if (!!this.metricPrefix) { 53 | min = `${this.metricPrefix}_min` in data ? data[`${this.metricPrefix}_min`] : 0; 54 | max = `${this.metricPrefix}_max` in data ? data[`${this.metricPrefix}_max`] : 0; 55 | avg = `${this.metricPrefix}_avg` in data ? data[`${this.metricPrefix}_avg`] : 0; 56 | p95 = `${this.metricPrefix}_p95` in data ? data[`${this.metricPrefix}_p95`] : 0; 57 | } else { 58 | min = 'Min' in data ? data['Min'] : 0; 59 | max = 'Max' in data ? data['Max'] : 0; 60 | avg = 'Avg' in data ? data['Avg'] : 0; 61 | p95 = 'P95' in data ? data['P95'] : 0; 62 | } 63 | 64 | const humanize = new HumanizePipe(); 65 | let tooltip = ` ⌜ Min: ${humanize.transform(min, this.measurement)} 66 | ⌟ Max: ${humanize.transform(max, this.measurement)} 67 | ◦ Avg: ${humanize.transform(avg, this.measurement)}`; 68 | 69 | if (p95 !== 0 && p95 !== null ) { 70 | tooltip += ` 71 | • 95%: ${humanize.transform(p95, this.measurement)}`; 72 | } 73 | this.dataTooltip = tooltip; 74 | 75 | const g = svg.append('g'); 76 | 77 | // hrAxes 78 | g.append('line') 79 | .attr('class', 'latency-chart-x') 80 | .attr('x1', '0') 81 | .attr('stroke-dasharray', '1, 1') 82 | .attr('y1', '13px') 83 | .attr('x2', width) 84 | .attr('y2', '13px'); 85 | 86 | // hrLine 87 | g.append('line') 88 | .attr('class', 'latency-chart-line') 89 | .attr('x1', x(min) + '') 90 | .attr('y1', '13px') 91 | .attr('x2', x(max) + '') 92 | .attr('y2', '13px'); 93 | 94 | // minMark 95 | g.append('line') 96 | .attr('class', 'latency-chart-min') 97 | .attr('x1', x(min) + '') 98 | .attr('y1', '13px') 99 | .attr('x2', x(min) + '') 100 | .attr('y2', '19px'); 101 | 102 | // maxMark 103 | g.append('line') 104 | .attr('class', 'latency-chart-max') 105 | .attr('x1', x(max) + '') 106 | .attr('y1', '8px') 107 | .attr('x2', x(max) + '') 108 | .attr('y2', '13px'); 109 | 110 | // avgMark 111 | g.append('circle') 112 | .attr('class', 'latency-chart-avg') 113 | .attr('r', 3) 114 | .attr('cx', x(avg) + '') 115 | .attr('cy', '13px'); 116 | 117 | // p95Mark 118 | if (p95 > 0) { 119 | g.append('circle') 120 | .attr('class', 'latency-chart-p95') 121 | .attr('r', 2) 122 | .attr('cx', x(p95) + '') 123 | .attr('cy', '13px'); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/app/shared/load-sparklines.directive.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { ElementRef } from '@angular/core'; 5 | import { LoadSparklinesDirective } from './load-sparklines.directive'; 6 | 7 | describe('LoadSparklinesDirective', () => { 8 | it('should create an instance', () => { 9 | const elementRef = new ElementRef('
'); 10 | const directive = new LoadSparklinesDirective(elementRef); 11 | expect(directive).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/shared/load-sparklines.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input, HostBinding } from '@angular/core'; 2 | import { ElementRef } from '@angular/core'; 3 | 4 | import * as moment from 'moment'; 5 | import { select } from 'd3-selection'; 6 | import { scaleLinear, scaleTime } from 'd3-scale'; 7 | import { isoParse, utcFormat, extent, line, area, bisector } from 'd3'; 8 | import { event as currentEvent, mouse } from 'd3-selection'; 9 | 10 | import { HumanizePipe } from './humanize.pipe'; 11 | import { MomentFormatPipe } from './moment-format.pipe'; 12 | 13 | /** 14 | * Display sparklines in top queries and metrics. 15 | */ 16 | @Directive({ selector: '[appLoadSparklines]' }) 17 | export class LoadSparklinesDirective { 18 | 19 | protected _xkey: string; 20 | protected _ykey: string; 21 | protected _measurement: string; 22 | 23 | humanize = new HumanizePipe(); 24 | dateFormat = new MomentFormatPipe(); 25 | 26 | @HostBinding('attr.data-tooltip') 27 | @Input() dataTooltip: string; 28 | 29 | constructor( 30 | public elementRef: ElementRef, 31 | ) { } 32 | 33 | @Input() set xkey(xkey: string) { 34 | this._xkey = xkey; 35 | } 36 | 37 | @Input() set ykey(ykey: string) { 38 | this._ykey = ykey; 39 | } 40 | 41 | @Input() set measurement(measurement: string) { 42 | this._measurement = measurement; 43 | } 44 | 45 | @Input() set appLoadSparklines(data: Array<{}>) { 46 | if (data !== null) { 47 | setTimeout(() => { 48 | this.drawChart(data) 49 | }, 1); 50 | } 51 | } 52 | 53 | drawChart(data: Array<{}>) { 54 | const xkey = this._xkey; 55 | const ykey = this._ykey; 56 | const measurement = this._measurement || 'number'; 57 | 58 | const chart = select(this.elementRef.nativeElement); 59 | chart.selectAll('*').remove(); 60 | const svg: any = chart.append('svg') 61 | .attr('height', '20') 62 | .attr('width', '100') 63 | .attr('class', 'scaling-svg') 64 | .attr('preserveAspectRatio', 'none') 65 | .attr('viewBox', '-1 0 102 20'); 66 | 67 | const height = 15; 68 | const width = Math.floor(svg.node().getBoundingClientRect().width); 69 | svg.attr('width', width).attr('viewBox', '-1 0 ' + (width + 2) + ' 20'); 70 | 71 | const xDomain = extent(data.map(d => moment.utc(d[xkey]))); 72 | 73 | const xScale = scaleTime().range([2, width - 2]).domain(xDomain); 74 | 75 | const yDomain = extent(data.map(d => ykey in d ? d[ykey] : 0)); 76 | 77 | const yScale = scaleLinear().range([height, 2]).domain(yDomain).clamp(true); 78 | 79 | const svgLine = line() 80 | .defined(d => !d['NoData']) 81 | .x(d => xScale(moment.utc(d[xkey]))) 82 | .y(d => yScale(d[ykey] === undefined ? 0 : d[ykey])); 83 | 84 | const svgArea = area() 85 | .defined(d => !d['NoData']) 86 | .x(d => xScale(moment.utc(d[xkey]))) 87 | .y0(d => yScale(d[ykey] === undefined ? 0 : d[ykey])) 88 | .y1(height - 1); 89 | 90 | const g = svg.append('g').attr('transform', 'translate(0, 0)'); 91 | 92 | g.append('path') 93 | .datum(data) 94 | .attr('class', 'area') 95 | .attr('d', svgArea); 96 | 97 | g.append('path') 98 | .datum(data) 99 | .attr('class', 'line') 100 | .attr('d', svgLine); 101 | 102 | g.append('line') 103 | .attr('x1', width + 20) 104 | .attr('y1', height) 105 | .attr('x2', '0') 106 | .attr('y2', height) 107 | .attr('class', 'x-axis'); 108 | 109 | const focus = g.append('g').style('display', 'none'); 110 | 111 | focus.append('line') 112 | .attr('id', 'focusLineX') 113 | .attr('class', 'focusLine'); 114 | 115 | focus.append('circle') 116 | .attr('id', 'focusCircle') 117 | .attr('r', 1.5) 118 | .attr('class', 'circle focusCircle'); 119 | 120 | focus.append('text') 121 | .attr('id', 'focusText') 122 | .attr('font-size', '10') 123 | .attr('x', 1) 124 | .attr('y', 8); 125 | 126 | // @ts-ignore TS2345 127 | const bisectDate = bisector((d, x) => moment.utc(d[xkey]).isBefore(x)).right; 128 | 129 | const rect = g.append('rect') 130 | .attr('class', 'overlay') 131 | .attr('width', width) 132 | .attr('height', height) 133 | .on('mouseover', () => focus.style('display', null)) 134 | .on('mouseout', () => focus.style('display', 'none')); 135 | 136 | rect.on('mousemove', (p, e) => { 137 | const coords = mouse(currentEvent.currentTarget); 138 | 139 | const mouseDate: any = moment.utc(xScale.invert(coords[0])); 140 | // returns the index to the current data item 141 | const i = Math.min(Math.max(bisectDate(data, mouseDate), 0), data.length - 1); 142 | let d = data[i]; 143 | 144 | // correction bisector to use data[0] on right edge of sparkline. 145 | if (i === 1) { 146 | const d0 = moment.utc(data[0][xkey]); 147 | const d1 = moment.utc(data[1][xkey]); 148 | if (mouseDate.diff(d1) > 0 && d0.diff(mouseDate) < mouseDate.diff(d1)) { 149 | d = data[0]; 150 | } 151 | } 152 | 153 | const x = xScale(isoParse(d[xkey])); 154 | const y = yScale(d[ykey] === undefined ? 0 : d[ykey]); 155 | 156 | const MIN = 0, 157 | MAX = 1; 158 | focus.select('#focusCircle') 159 | .attr('cx', x) 160 | .attr('cy', y); 161 | focus.select('#focusLineX') 162 | .attr('x1', x).attr('y1', yScale(yDomain[MIN])) 163 | .attr('x2', x).attr('y2', yScale(yDomain[MAX])); 164 | 165 | const value = d[ykey] === undefined ? 0 : d[ykey]; 166 | const load = this.humanize.transform(value, measurement); 167 | 168 | const dateToShow = this.dateFormat.transform(moment(d[xkey]).utc()); 169 | this.dataTooltip = d['NoData'] ? `No data at ${dateToShow}` : `${load} at ${dateToShow}`; 170 | }); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/app/shared/map-to-iterable.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { MapToIterablePipe } from './map-to-iterable.pipe'; 5 | 6 | describe('MapToIterablePipe', () => { 7 | it('create an instance', () => { 8 | const pipe = new MapToIterablePipe(); 9 | expect(pipe).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/shared/map-to-iterable.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | 4 | @Pipe({ 5 | name: 'mapToIterable' 6 | }) 7 | export class MapToIterablePipe implements PipeTransform { 8 | transform(dict: Object): Array<{}> { 9 | if (dict === null) { 10 | return null; 11 | } 12 | const a: Array<{}> = []; 13 | for (const key in dict) { 14 | if (dict.hasOwnProperty(key)) { 15 | a.push({key: key, val: dict[key]}); 16 | } 17 | } 18 | return a; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/shared/moment-format.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { MomentFormatPipe } from './moment-format.pipe'; 5 | 6 | describe('MomentFormatPipe', () => { 7 | it('create an instance', () => { 8 | const pipe = new MomentFormatPipe(); 9 | expect(pipe).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/shared/moment-format.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import * as moment from 'moment'; 3 | 4 | @Pipe({ name: 'dateFormat' }) 5 | export class MomentFormatPipe implements PipeTransform { 6 | 7 | timezone = 'browser'; 8 | constructor () { 9 | this.timezone = this.getCookie('timezone') || 'browser'; 10 | } 11 | 12 | getCookie(name) { 13 | return document.cookie.split('; ').reduce((r, v) => { 14 | const parts = v.split('='); 15 | return parts[0] === name ? decodeURIComponent(parts[1]) : r; 16 | }, ''); 17 | } 18 | 19 | transform(value: any, format = 'YYYY-MM-DD HH:mm:ss'): string { 20 | if (value === null) { 21 | return null; 22 | } 23 | 24 | if (this.timezone === 'browser') { 25 | return value.local().format(format); 26 | } else { 27 | return value.format(format); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/shared/parse-query-param-date.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ParseQueryParamDatePipe } from './parse-query-param-date.pipe'; 2 | 3 | describe('ParseQueryParamDatePipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new ParseQueryParamDatePipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/shared/parse-query-param-date.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { MomentFormatPipe } from './moment-format.pipe'; 3 | import * as moment from 'moment'; 4 | 5 | type TimeEdge = 'from' | 'to'; 6 | 7 | /** 8 | * 9 | * var spans = { 10 | * 's': {display: 'second'}, 11 | * 'm': {display: 'minute'}, 12 | * 'h': {display: 'hour'}, 13 | * 'd': {display: 'day'}, 14 | * 'w': {display: 'week'}, 15 | * 'M': {display: 'month'}, 16 | * 'y': {display: 'year'}, 17 | * }; 18 | * 19 | * var rangeOptions = [ 20 | * { from: 'now/d', to: 'now/d', display: 'Today', section: 2 }, 21 | * { from: 'now/d', to: 'now', display: 'Today so far', section: 2 }, 22 | * { from: 'now/w', to: 'now/w', display: 'This week', section: 2 }, 23 | * { from: 'now/w', to: 'now', display: 'This week so far', section: 2 }, 24 | * { from: 'now/M', to: 'now/M', display: 'This month', section: 2 }, 25 | * { from: 'now/y', to: 'now/y', display: 'This year', section: 2 }, 26 | * 27 | * { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 }, 28 | * { from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 1 }, 29 | * { from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 1 }, 30 | * { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 }, 31 | * { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 }, 32 | * { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 }, 33 | * 34 | * { from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 }, 35 | * { from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 }, 36 | * { from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 }, 37 | * { from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 }, 38 | * { from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 }, 39 | * { from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 }, 40 | * { from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 }, 41 | * { from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 }, 42 | * 43 | * { from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 }, 44 | * { from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 }, 45 | * { from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 }, 46 | * { from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 }, 47 | * { from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 }, 48 | * { from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 }, 49 | * { from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 }, 50 | * { from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 }, 51 | * ];from=now-7d%2Fd&to=now-7d%2Fd 52 | */ 53 | @Pipe({ 54 | name: 'parseQueryParamDate' 55 | }) 56 | export class ParseQueryParamDatePipe implements PipeTransform { 57 | 58 | transform(date: string, edge: TimeEdge) { 59 | const momentFormatPipe = new MomentFormatPipe(); 60 | const nowFunc = momentFormatPipe.timezone === 'utc' ? moment.utc : moment; 61 | let parsedDate; 62 | // from=now 63 | 64 | if (date === undefined && edge === 'from') { 65 | return nowFunc().subtract(1, 'h'); 66 | } 67 | if (date === undefined && edge === 'to') { 68 | return nowFunc(); 69 | } 70 | 71 | if (date === 'now') { 72 | return nowFunc(); 73 | } 74 | // from=now-5d&to=now-6M ... from=now/w&to=now/w 75 | if (date.length > 4 && date.startsWith('now')) { 76 | // let subtrahend = date.substr(4); 77 | // ex: ["now-7d/d", "now", "-", "7", "d", "/", "d"] 78 | const parts = date.match('(now)(-|/)?([0-9]*)([yMwdhms])(/)?([yMwdhms])?'); 79 | 80 | if (parts[1] === 'now') { 81 | parsedDate = nowFunc(); 82 | } 83 | if (parts[2] === '-') { 84 | parsedDate.subtract(parts[3], parts[4]); 85 | } 86 | if (parts[2] === '/') { 87 | if (edge === 'from') { 88 | return parsedDate.startOf(parts[4]); 89 | } else { 90 | return parsedDate.endOf(parts[4]); 91 | } 92 | } 93 | if (parts.length > 4 && parts[5] === '/') { 94 | if (edge === 'from') { 95 | return parsedDate.startOf(parts[6]); 96 | } else { 97 | return parsedDate.endOf(parts[6]); 98 | } 99 | } 100 | } else { 101 | // expect unix timestamp in milliseconds 102 | const isnum = /^\d+$/.test(date); 103 | if (isnum) { 104 | return nowFunc(parseInt(date, 10)); 105 | } else { 106 | return nowFunc(date); 107 | } 108 | } 109 | return parsedDate; 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {FormsModule} from '@angular/forms'; 4 | import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; 5 | import {RouterModule} from '@angular/router'; 6 | import {HumanizePipe} from './humanize.pipe'; 7 | import {LatencyChartDirective} from './latency-chart.directive'; 8 | import {LoadSparklinesDirective} from './load-sparklines.directive'; 9 | import {MapToIterablePipe} from './map-to-iterable.pipe'; 10 | import {MomentFormatPipe} from './moment-format.pipe'; 11 | import {TruncateRootPipe} from './truncate-root.pipe'; 12 | import {ParseQueryParamDatePipe} from './parse-query-param-date.pipe'; 13 | import {SortingTablePipe} from './sorting-table.pipe'; 14 | import {HttpClientModule} from '@angular/common/http'; 15 | 16 | @NgModule({ 17 | imports: [ 18 | CommonModule 19 | ], 20 | declarations: [ 21 | HumanizePipe, 22 | LatencyChartDirective, 23 | LoadSparklinesDirective, 24 | MapToIterablePipe, 25 | MomentFormatPipe, 26 | TruncateRootPipe, 27 | ParseQueryParamDatePipe, 28 | SortingTablePipe 29 | ], 30 | exports: [ 31 | MapToIterablePipe, 32 | MomentFormatPipe, 33 | TruncateRootPipe, 34 | HumanizePipe, 35 | LatencyChartDirective, 36 | LoadSparklinesDirective, 37 | SortingTablePipe, 38 | CommonModule, 39 | FormsModule, 40 | HttpClientModule, 41 | NgbModule, 42 | RouterModule 43 | ] 44 | }) 45 | export class SharedModule { 46 | static forRoot() { 47 | return { 48 | ngModule: SharedModule, 49 | providers: [], 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/shared/sorting-table.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { SortingTablePipe } from './sorting-table.pipe'; 2 | 3 | describe('sortingInstancesPipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new SortingTablePipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/shared/sorting-table.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | 3 | @Pipe({name: 'sortingTable'}) 4 | export class SortingTablePipe implements PipeTransform { 5 | 6 | transform(instance: any[], path: string[], order: number = 1): any[] { 7 | // Check if is not null 8 | if (!instance || !path || !order) { 9 | return instance; 10 | } 11 | 12 | return instance.sort((a: any, b: any) => { 13 | // We go for each property followed by path 14 | path.forEach(property => { 15 | a = a[property]; 16 | b = b[property]; 17 | }); 18 | 19 | // Order * (-1): We change our order 20 | return a > b ? order : order * (-1); 21 | }) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/app/shared/truncate-root.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from '@angular/core/testing'; 4 | import { TruncateRootPipe } from './truncate-root.pipe'; 5 | 6 | describe('TruncateRootPipe', () => { 7 | it('create an instance', () => { 8 | const pipe = new TruncateRootPipe(); 9 | expect(pipe).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/shared/truncate-root.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'truncateRoot' }) 4 | export class TruncateRootPipe implements PipeTransform { 5 | transform(input: string, len: number): string { 6 | if (input === null) { 7 | return null; 8 | } 9 | if (input.length > len && len > 4) { 10 | let res = ''; 11 | if (len % 2 === 0) { 12 | const half = len / 2; 13 | res = input.substring(0, half - 1); 14 | res += '...'; 15 | res += input.substring(input.length - half + 2, input.length); 16 | } else { 17 | const half = Math.floor(len / 2); 18 | res = input.substring(0, half - 1); 19 | res += '...'; 20 | res += input.substring(input.length - half + 1, input.length); 21 | } 22 | return res; 23 | } else { 24 | return input; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/summary/summary.component.html: -------------------------------------------------------------------------------- 1 |
2 | 7 |
8 | 9 | 17 |
18 |
19 | 20 | 21 | 22 |
23 |
System Summary
24 |
25 |
26 | 27 |
{{ serverSummary }}
28 | 29 | 33 |
34 |
35 | 36 | 37 |
38 |
MySQL Summary
39 |
40 |
41 | 42 |
{{ mysqlSummary }}
43 | 44 | 48 |
49 |
50 | 51 | 52 |
53 |
MongoDB Summary
54 |
55 |
56 | 57 |
{{ mongoSummary }}
58 | 59 | 63 |
64 |
65 |
66 |
67 |
68 | -------------------------------------------------------------------------------- /src/app/summary/summary.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/app/summary/summary.component.scss -------------------------------------------------------------------------------- /src/app/summary/summary.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { DebugElement } from '@angular/core'; 5 | 6 | import { SummaryComponent } from './summary.component'; 7 | 8 | describe('SummaryComponent', () => { 9 | let component: SummaryComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async(() => { 13 | TestBed.configureTestingModule({ 14 | declarations: [ SummaryComponent ] 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(SummaryComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/summary/summary.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router, ActivatedRoute, Params } from '@angular/router'; 3 | 4 | import { CoreComponent } from '../core/core.component'; 5 | import { SummaryService } from './summary.service'; 6 | import { Instance, InstanceService } from '../core/instance.service'; 7 | import * as JSZip from 'jszip'; 8 | import saveAs from 'jszip/vendor/FileSaver'; 9 | import * as moment from 'moment'; 10 | import { MomentFormatPipe } from '../shared/moment-format.pipe'; 11 | 12 | 13 | /** 14 | * Shows MySQL and Server Summary 15 | */ 16 | @Component({ 17 | moduleId: module.id, 18 | selector: 'app-summary', 19 | templateUrl: './summary.component.html', 20 | styleUrls: ['./summary.component.scss'] 21 | }) 22 | export class SummaryComponent extends CoreComponent { 23 | 24 | public serverSummary: string; 25 | public mysqlSummary: string; 26 | public mongoSummary: string; 27 | 28 | public serverSummaryError: string; 29 | public mysqlSummaryError: string; 30 | public mongoSummaryError: string; 31 | 32 | public serverSummaryLoader: boolean; 33 | public mysqlSummaryLoader: boolean; 34 | public mongoSummaryLoader: boolean; 35 | 36 | constructor(protected route: ActivatedRoute, protected router: Router, 37 | protected summaryService: SummaryService, protected instanceService: InstanceService) { 38 | super(route, router, instanceService); 39 | } 40 | 41 | /** 42 | * Gets MySQL summary via API, Agent from `pt-summary`. 43 | * @param agentUUID agent UUID that is installed on same host as MySQL. 44 | */ 45 | getServerSummary(agentUUID: string): void { 46 | this.summaryService 47 | .getServer(agentUUID, this.dbServer.ParentUUID) 48 | .then(data => this.serverSummary = data) 49 | .catch(err => this.serverSummaryError = err.message) 50 | .then(() => this.serverSummaryLoader = false); 51 | } 52 | 53 | /** 54 | * Gets MySQL summary via API, Agent from `pt-mysql-summary`. 55 | * @param agentUUID agent UUID that is monitoring MySQL Server. 56 | */ 57 | getMySQLSummary(agentUUID: string): void { 58 | this.summaryService 59 | .getMySQL(agentUUID, this.dbServer.UUID) 60 | .then(data => this.mysqlSummary = data) 61 | .catch(err => this.mysqlSummaryError = err.message) 62 | .then(() => this.mysqlSummaryLoader = false); 63 | } 64 | 65 | /** 66 | * Gets MongoDB summary via API, Agent from `pt-mongodb-summary`. 67 | * @param agentUUID agent UUID that is monitoring MongoDB Server. 68 | */ 69 | getMongoSummary(agentUUID: string): void { 70 | this.summaryService 71 | .getMongo(agentUUID, this.dbServer.UUID) 72 | .then(data => this.mongoSummary = data) 73 | .catch(err => this.mongoSummaryError = err.message) 74 | .then(() => this.mongoSummaryLoader = false); 75 | } 76 | 77 | downloadSummary() { 78 | const momentFormatPipe = new MomentFormatPipe(); 79 | const date = momentFormatPipe.transform(moment.utc(), 'YYYY-MM-DDTHH:mm:ss'); 80 | const filename = `pmm-${this.dbServer.Name}-${date}-summary.zip`; 81 | const zip = new JSZip(); 82 | zip.file('system_summary.txt', this.serverSummary); 83 | if (this.dbServer.Subsystem === 'mongo') { 84 | zip.file('server_summary.txt', this.mongoSummary); 85 | } else if (this.dbServer.Subsystem === 'mysql') { 86 | zip.file('server_summary.txt', this.mysqlSummary); 87 | } 88 | zip.generateAsync({type: 'blob'}) 89 | .then(function(content) { 90 | // see FileSaver.js 91 | saveAs(content, filename); 92 | }); 93 | } 94 | 95 | 96 | /** 97 | * Ovverrides parent method. 98 | * Executes on route was changed to refresh data. 99 | * @param params - URL query parameters 100 | */ 101 | onChangeParams(params) { 102 | // to initalise loader when host was changed 103 | this.mysqlSummary = '', 104 | this.mongoSummary = '', 105 | this.serverSummary = '', 106 | this.mysqlSummaryError = '', 107 | this.mongoSummaryError = '', 108 | this.serverSummaryError = ''; 109 | this.mysqlSummaryLoader = true, 110 | this.mongoSummaryLoader = true, 111 | this.serverSummaryLoader = true; 112 | 113 | this.getServerSummary(this.agent.UUID); 114 | if (this.dbServer.Subsystem === 'mysql') { 115 | this.getMySQLSummary(this.agent.UUID); 116 | } 117 | if (this.dbServer.Subsystem === 'mongo') { 118 | this.getMongoSummary(this.agent.UUID) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/app/summary/summary.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async, inject } from '@angular/core/testing'; 4 | import { SummaryService } from './summary.service'; 5 | 6 | describe('SummaryService', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | providers: [SummaryService] 10 | }); 11 | }); 12 | 13 | it('should ...', inject([SummaryService], (service: SummaryService) => { 14 | expect(service).toBeTruthy(); 15 | })); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/summary/summary.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {HttpClient, HttpHeaders} from '@angular/common/http'; 3 | 4 | @Injectable() 5 | export class SummaryService { 6 | 7 | private headers = new HttpHeaders({ 'Content-Type': 'application/json' }); 8 | 9 | constructor(private httpClient: HttpClient) { } 10 | 11 | public getServer(agentUUID: string, serverUUID: string) { 12 | const url = `/qan-api/agents/${agentUUID}/cmd`; 13 | const data = { 14 | UUID: serverUUID, 15 | }; 16 | const params = { 17 | AgentUUID: agentUUID, 18 | Service: 'query', 19 | Cmd: 'Summary', 20 | Data: btoa(JSON.stringify(data)) 21 | }; 22 | 23 | return this.httpClient 24 | .put(url, params, { headers: this.headers }) 25 | .toPromise() 26 | .then(resp => { 27 | // if not error - continue 28 | if (!resp['Error']) { 29 | return resp; 30 | } 31 | let err = resp['Error']; 32 | if (resp['Error'] === 'Executable file not found in $PATH') { 33 | err = ' - Please install `pt-summary`.'; 34 | err += ' (Output: ' + resp['Error'] + ')'; 35 | } 36 | throw new Error(err); 37 | }) 38 | .then(resp => { 39 | let str = window.atob(resp['Data']); 40 | str = str.replace(/\\n/g, '\n'); 41 | str = str.replace(/\\t/g, '\t'); 42 | return str.slice(1, -1); 43 | }); 44 | } 45 | 46 | getMySQL(agentUUID: string, dbServerUUID: string) { 47 | const url = `/qan-api/agents/${agentUUID}/cmd`; 48 | const data = { 49 | UUID: dbServerUUID 50 | }; 51 | const params = { 52 | AgentUUID: agentUUID, 53 | Service: 'query', 54 | Cmd: 'Summary', 55 | Data: btoa(JSON.stringify(data)) 56 | }; 57 | 58 | return this.httpClient 59 | .put(url, params, { headers: this.headers }) 60 | .toPromise() 61 | .then(resp => { 62 | // if not error - continue 63 | if (!resp['Error']) { 64 | return resp; 65 | } 66 | let err = resp['Error']; 67 | if (resp['Error'] === 'Executable file not found in $PATH') { 68 | err = ' - Please install `pt-mysql-summary`.'; 69 | err += ' (Output: ' + resp['Error'] + ')'; 70 | } 71 | throw new Error(err); 72 | }) 73 | .then(resp => { 74 | let str = window.atob(resp['Data']); 75 | str = str.replace(/\\n/g, '\n'); 76 | str = str.replace(/\\t/g, '\t'); 77 | return str.slice(1, -1); 78 | }); 79 | } 80 | 81 | getMongo(agentUUID: string, dbServerUUID: string) { 82 | const url = `/qan-api/agents/${agentUUID}/cmd`; 83 | const data = { 84 | UUID: dbServerUUID 85 | }; 86 | 87 | const params = { 88 | AgentUUID: agentUUID, 89 | Service: 'query', 90 | Cmd: 'Summary', 91 | Data: btoa(JSON.stringify(data)) 92 | }; 93 | 94 | return this.httpClient 95 | .put(url, params, { headers: this.headers }) 96 | .toPromise() 97 | .then(resp => { 98 | // if not error - continue 99 | if (!resp['Error']) { 100 | return resp; 101 | } 102 | let err = resp['Error']; 103 | if (resp['Error'] === 'Executable file not found in $PATH') { 104 | err = ' - Please install `pt-mongodb-summary`.'; 105 | err += ' (Output: ' + resp['Error'] + ')'; 106 | } 107 | if (resp['Error'] === 'Unknown command: GetMongoSummary') { 108 | err = ' - Please update your `pmm-client`.'; 109 | err += ' (Output: ' + resp['Error'] + ')'; 110 | } 111 | throw new Error(err); 112 | }) 113 | .then(resp => { 114 | let str = window.atob(resp['Data']); 115 | str = str.replace(/\\n/g, '\n'); 116 | str = str.replace(/\\t/g, '\t'); 117 | return str.slice(1, -1); 118 | }); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/50747258.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/50747258.png -------------------------------------------------------------------------------- /src/assets/Percona_XtraDB_Cluster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/Percona_XtraDB_Cluster.png -------------------------------------------------------------------------------- /src/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/background.png -------------------------------------------------------------------------------- /src/assets/database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/database.png -------------------------------------------------------------------------------- /src/assets/footer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/footer-logo.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/mysql_64_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/mysql_64_black.png -------------------------------------------------------------------------------- /src/assets/percona-server-black-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/percona-server-black-50.png -------------------------------------------------------------------------------- /src/assets/pmm-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 60 | 68 | 69 | 70 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | -------------------------------------------------------------------------------- /src/assets/sm-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/assets/sm-logo.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | demoHosts: ['163.172.51.248', 'pmmdemo.percona.com'], 4 | version: require('../../package.json').version 5 | }; 6 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false, 8 | demoHosts: ['163.172.51.248', 'pmmdemo.percona.com'], 9 | version: 'dev' 10 | }; 11 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percona/qan-app/dc15e33d41a0b87abec08abb83aded5b0f83356a/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Percona Query Analytics 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | import { enableProdMode } from '@angular/core'; 3 | import { environment } from './environments/environment'; 4 | import { AppModule } from './app/app.module'; 5 | 6 | if (environment.production) { 7 | enableProdMode(); 8 | } 9 | 10 | platformBrowserDynamic().bootstrapModule(AppModule); 11 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | // This file includes polyfills needed by Angular and is loaded before 2 | // the app. You can add your own extra polyfills to this file. 3 | import 'core-js/es6/symbol'; 4 | import 'core-js/es6/object'; 5 | import 'core-js/es6/function'; 6 | import 'core-js/es6/parse-int'; 7 | import 'core-js/es6/parse-float'; 8 | import 'core-js/es6/number'; 9 | import 'core-js/es6/math'; 10 | import 'core-js/es6/string'; 11 | import 'core-js/es6/date'; 12 | import 'core-js/es6/array'; 13 | import 'core-js/es6/regexp'; 14 | import 'core-js/es6/map'; 15 | import 'core-js/es6/set'; 16 | import 'core-js/es6/reflect'; 17 | 18 | import 'core-js/es7/reflect'; 19 | import 'zone.js/dist/zone'; 20 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/dist/long-stack-trace-zone'; 2 | import 'zone.js/dist/proxy.js'; 3 | import 'zone.js/dist/sync-test'; 4 | import 'zone.js/dist/jasmine-patch'; 5 | import 'zone.js/dist/async-test'; 6 | import 'zone.js/dist/fake-async-test'; 7 | import { getTestBed } from '@angular/core/testing'; 8 | import { 9 | BrowserDynamicTestingModule, 10 | platformBrowserDynamicTesting 11 | } from '@angular/platform-browser-dynamic/testing'; 12 | 13 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 14 | declare var __karma__: any; 15 | declare var require: any; 16 | 17 | // Prevent Karma from running prematurely. 18 | __karma__.loaded = function () {}; 19 | 20 | // First, initialize the Angular testing environment. 21 | getTestBed().initTestEnvironment( 22 | BrowserDynamicTestingModule, 23 | platformBrowserDynamicTesting() 24 | ); 25 | // Then we find all the tests. 26 | const context = require.context('./', true, /\.spec\.ts$/); 27 | // And load the modules. 28 | context.keys().map(context); 29 | // Finally, start Karma to run the tests. 30 | __karma__.start(); 31 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": [ 9 | "es2016", 10 | "dom" 11 | ], 12 | "outDir": "../out-tsc/app", 13 | "target": "es5", 14 | "module": "es2015", 15 | "baseUrl": "", 16 | "types": [] 17 | }, 18 | "exclude": [ 19 | "test.ts", 20 | "**/*.spec.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": [ 9 | "es2016", 10 | "dom" 11 | ], 12 | "outDir": "../out-tsc/spec", 13 | "module": "commonjs", 14 | "target": "es6", 15 | "baseUrl": "", 16 | "types": [ 17 | "jasmine", 18 | "node" 19 | ] 20 | }, 21 | "files": [ 22 | "test.ts", 23 | "polyfills.ts" 24 | ], 25 | "include": [ 26 | "**/*.spec.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'moment-timezone'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "lib": [ 11 | "es2016", 12 | "dom" 13 | ], 14 | "module": "es2015", 15 | "baseUrl": "./" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "callable-types": true, 7 | "class-name": true, 8 | "comment-format": [ 9 | true, 10 | "check-space" 11 | ], 12 | "curly": true, 13 | "eofline": true, 14 | "forin": true, 15 | "import-blacklist": [true], 16 | "import-spacing": true, 17 | "indent": [ 18 | true, 19 | "spaces" 20 | ], 21 | "interface-over-type-literal": true, 22 | "label-position": true, 23 | "max-line-length": [ 24 | true, 25 | 140 26 | ], 27 | "member-access": false, 28 | "member-ordering": [ 29 | true, 30 | "static-before-instance", 31 | "variables-before-functions" 32 | ], 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-console": [ 36 | true, 37 | "debug", 38 | "info", 39 | "time", 40 | "timeEnd", 41 | "trace" 42 | ], 43 | "no-construct": true, 44 | "no-debugger": true, 45 | "no-duplicate-variable": true, 46 | "no-empty": false, 47 | "no-empty-interface": true, 48 | "no-eval": true, 49 | "no-inferrable-types": [true, "ignore-params"], 50 | "no-shadowed-variable": true, 51 | "no-string-literal": false, 52 | "no-string-throw": true, 53 | "no-switch-case-fall-through": true, 54 | "no-trailing-whitespace": true, 55 | "no-unused-expression": true, 56 | "no-use-before-declare": true, 57 | "no-var-keyword": true, 58 | "object-literal-sort-keys": false, 59 | "one-line": [ 60 | true, 61 | "check-open-brace", 62 | "check-catch", 63 | "check-else", 64 | "check-whitespace" 65 | ], 66 | "prefer-const": true, 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "radix": true, 72 | "semicolon": [ 73 | "always" 74 | ], 75 | "triple-equals": [ 76 | true, 77 | "allow-null-check" 78 | ], 79 | "typedef-whitespace": [ 80 | true, 81 | { 82 | "call-signature": "nospace", 83 | "index-signature": "nospace", 84 | "parameter": "nospace", 85 | "property-declaration": "nospace", 86 | "variable-declaration": "nospace" 87 | } 88 | ], 89 | "unified-signatures": true, 90 | "variable-name": false, 91 | "whitespace": [ 92 | true, 93 | "check-branch", 94 | "check-decl", 95 | "check-operator", 96 | "check-separator", 97 | "check-type" 98 | ], 99 | 100 | "directive-selector": [true, "attribute", "app", "camelCase"], 101 | "component-selector": [true, "element", "app", "kebab-case"], 102 | "no-inputs-metadata-property": true, 103 | "no-outputs-metadata-property": true, 104 | "no-host-metadata-property": true, 105 | "no-input-rename": true, 106 | "no-output-rename": true, 107 | "use-life-cycle-interface": true, 108 | "use-pipe-transform-interface": true, 109 | "component-class-suffix": true, 110 | "directive-class-suffix": true 111 | } 112 | } 113 | --------------------------------------------------------------------------------