diff --git a/.bra.toml b/.bra.toml index dcf316466d6..15961e1e3fd 100644 --- a/.bra.toml +++ b/.bra.toml @@ -9,7 +9,7 @@ watch_dirs = [ "$WORKDIR/public/views", "$WORKDIR/conf", ] -watch_exts = [".go", ".ini", ".toml"] +watch_exts = [".go", ".ini", ".toml", ".template.html"] build_delay = 1500 cmds = [ ["go", "run", "build.go", "-dev", "build-server"], diff --git a/.circleci/config.yml b/.circleci/config.yml index 3aedd49c935..1e046aec34d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,9 +5,14 @@ aliases: ignore: /.*/ tags: only: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ - - &filter-not-release + - &filter-not-release-or-master tags: ignore: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ + branches: + ignore: master + - &filter-only-master + branches: + only: master version: 2 @@ -85,6 +90,9 @@ jobs: - run: name: run linters command: 'gometalinter.v2 --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...' + - run: + name: run go vet + command: 'go vet ./pkg/...' test-frontend: docker: @@ -96,6 +104,7 @@ jobs: - run: name: yarn install command: 'yarn install --pure-lockfile --no-progress' + no_output_timeout: 15m - save_cache: key: dependency-cache-{{ checksum "yarn.lock" }} paths: @@ -138,6 +147,12 @@ jobs: - run: name: sign packages command: './scripts/build/sign_packages.sh' + - run: + name: verify signed packages + command: | + mkdir -p ~/.rpmdb/pubkeys + curl -s https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana > ~/.rpmdb/pubkeys/grafana.key + ./scripts/build/verify_signed_packages.sh dist/*.rpm - run: name: sha-sum packages command: 'go run build.go sha-dist' @@ -150,8 +165,65 @@ jobs: - dist/grafana* - scripts/*.sh - scripts/publish - - store_artifacts: - path: dist + + build: + docker: + - image: grafana/build-container:1.0.0 + working_directory: /go/src/github.com/grafana/grafana + steps: + - checkout + - run: + name: prepare build tools + command: '/tmp/bootstrap.sh' + - run: + name: build and package grafana + command: './scripts/build/build.sh' + - run: + name: sign packages + command: './scripts/build/sign_packages.sh' + - run: + name: sha-sum packages + command: 'go run build.go sha-dist' + - persist_to_workspace: + root: . + paths: + - dist/grafana* + + grafana-docker-master: + docker: + - image: docker:stable-git + steps: + - checkout + - attach_workspace: + at: . + - setup_remote_docker + - run: docker info + - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker + - run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}" + + grafana-docker-pr: + docker: + - image: docker:stable-git + steps: + - checkout + - attach_workspace: + at: . + - setup_remote_docker + - run: docker info + - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker + - run: cd packaging/docker && ./build.sh "${CIRCLE_SHA1}" + + grafana-docker-release: + docker: + - image: docker:stable-git + steps: + - checkout + - attach_workspace: + at: . + - setup_remote_docker + - run: docker info + - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker + - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}" build-enterprise: docker: @@ -160,7 +232,7 @@ jobs: steps: - checkout - run: - name: build and package grafana + name: build, test and package grafana enterprise command: './scripts/build/build_enterprise.sh' - run: name: sign packages @@ -168,6 +240,26 @@ jobs: - run: name: sha-sum packages command: 'go run build.go sha-dist' + - run: + name: move enterprise packages into their own folder + command: 'mv dist enterprise-dist' + - persist_to_workspace: + root: . + paths: + - enterprise-dist/grafana-enterprise* + + deploy-enterprise-master: + docker: + - image: circleci/python:2.7-stretch + steps: + - attach_workspace: + at: . + - run: + name: install awscli + command: 'sudo pip install awscli' + - run: + name: deploy to s3 + command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master' deploy-master: docker: @@ -187,9 +279,6 @@ jobs: - run: name: Trigger Windows build command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master' - - run: - name: Trigger Docker build - command: './scripts/trigger_docker_build.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} master-$(echo "${CIRCLE_SHA1}" | cut -b1-7)' - run: name: Publish to Grafana.com command: | @@ -211,28 +300,27 @@ jobs: - run: name: Trigger Windows build command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release' - - run: - name: Trigger Docker build - command: './scripts/trigger_docker_build.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} ${CIRCLE_TAG}' workflows: version: 2 - test-and-build: + build-master: jobs: - build-all: - filters: *filter-not-release + filters: *filter-only-master + - build-enterprise: + filters: *filter-only-master - codespell: - filters: *filter-not-release + filters: *filter-only-master - gometalinter: - filters: *filter-not-release + filters: *filter-only-master - test-frontend: - filters: *filter-not-release + filters: *filter-only-master - test-backend: - filters: *filter-not-release + filters: *filter-only-master - mysql-integration-test: - filters: *filter-not-release + filters: *filter-only-master - postgres-integration-test: - filters: *filter-not-release + filters: *filter-only-master - deploy-master: requires: - build-all @@ -242,9 +330,29 @@ workflows: - gometalinter - mysql-integration-test - postgres-integration-test - filters: - branches: - only: master + filters: *filter-only-master + - grafana-docker-master: + requires: + - build-all + - test-backend + - test-frontend + - codespell + - gometalinter + - mysql-integration-test + - postgres-integration-test + filters: *filter-only-master + - deploy-enterprise-master: + requires: + - build-all + - test-backend + - test-frontend + - codespell + - gometalinter + - mysql-integration-test + - postgres-integration-test + - build-enterprise + filters: *filter-only-master + release: jobs: - build-all: @@ -271,3 +379,40 @@ workflows: - mysql-integration-test - postgres-integration-test filters: *filter-only-release + - grafana-docker-release: + requires: + - build-all + - test-backend + - test-frontend + - codespell + - gometalinter + - mysql-integration-test + - postgres-integration-test + filters: *filter-only-release + + build-branches-and-prs: + jobs: + - build: + filters: *filter-not-release-or-master + - codespell: + filters: *filter-not-release-or-master + - gometalinter: + filters: *filter-not-release-or-master + - test-frontend: + filters: *filter-not-release-or-master + - test-backend: + filters: *filter-not-release-or-master + - mysql-integration-test: + filters: *filter-not-release-or-master + - postgres-integration-test: + filters: *filter-not-release-or-master + - grafana-docker-pr: + requires: + - build + - test-backend + - test-frontend + - codespell + - gometalinter + - mysql-integration-test + - postgres-integration-test + filters: *filter-not-release-or-master diff --git a/.dockerignore b/.dockerignore index e50dfd86aa3..c535fa427b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,9 +3,12 @@ .git .gitignore .github +.vscode +bin data* dist docker +Dockerfile docs dump.rdb node_modules diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fe0a1d6c548..769ba2a519b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,12 +2,12 @@ Follow the setup guide in README.md ### Rebuild frontend assets on source change ``` -grunt && grunt watch +yarn watch ``` ### Rerun tests on source change ``` -grunt karma:dev +yarn jest ``` ### Run tests for backend assets before commit @@ -17,6 +17,6 @@ test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' ### Run tests for frontend assets before commit ``` -npm test +yarn test go test -v ./pkg/... ``` diff --git a/.gitignore b/.gitignore index 25325b37890..2484176a469 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ fig.yml docker-compose.yml docker-compose.yaml /conf/provisioning/**/custom.yaml +/conf/provisioning/**/dev.yaml +/conf/ldap_dev.toml profile.cov /grafana /local @@ -56,6 +58,7 @@ debug.test /examples/*/dist /packaging/**/*.rpm /packaging/**/*.deb +/packaging/**/*.tar.gz # Ignore OSX indexing .DS_Store diff --git a/.jscs.json b/.jscs.json deleted file mode 100644 index 8fdad332de5..00000000000 --- a/.jscs.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "disallowImplicitTypeConversion": ["string"], - "disallowKeywords": ["with"], - "disallowMultipleLineBreaks": true, - "disallowMixedSpacesAndTabs": true, - "disallowTrailingWhitespace": true, - "requireSpacesInFunctionExpression": { - "beforeOpeningCurlyBrace": true - }, - "disallowSpacesInsideArrayBrackets": true, - "disallowSpacesInsideParentheses": true, - "validateIndentation": 2 -} diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 1d8fad63173..00000000000 --- a/.jshintrc +++ /dev/null @@ -1,37 +0,0 @@ -{ - "browser": true, - "esversion": 6, - "bitwise":false, - "curly": true, - "eqnull": true, - "strict": false, - "devel": true, - "eqeqeq": true, - "forin": false, - "immed": true, - "supernew": true, - "expr": true, - "indent": 2, - "latedef": false, - "newcap": true, - "noarg": true, - "noempty": true, - "undef": true, - "boss": true, - "trailing": true, - "laxbreak": true, - "laxcomma": true, - "sub": true, - "unused": true, - "maxdepth": 6, - "maxlen": 140, - - "globals": { - "System": true, - "Promise": true, - "define": true, - "require": true, - "Chromath": false, - "setImmediate": true - } -} diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ba9076774..98efa2b1099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,83 @@ # 5.3.0 (unreleased) +* **OAuth**: Gitlab OAuth with support for filter by groups [#5623](https://github.com/grafana/grafana/issues/5623), thx [@BenoitKnecht](https://github.com/BenoitKnecht) * **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano) * **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon) +* **LDAP**: Define Grafana Admin permission in ldap group mappings [#2469](https://github.com/grafana/grafana/issues/2496), PR [#12622](https://github.com/grafana/grafana/issues/12622) +* **Cloudwatch**: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda) +* **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos) +* **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476) +* **LDAP**: Client certificates support [#12805](https://github.com/grafana/grafana/issues/12805), thx [@nyxi](https://github.com/nyxi) +* **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm) ### Minor * **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley) * **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248) +* **Dashboard**: Use uid when linking to dashboards internally in a dashboard [#10705](https://github.com/grafana/grafana/issues/10705) * **Singlestat**: Make colorization of prefix and postfix optional in singlestat [#11892](https://github.com/grafana/grafana/pull/11892), thx [@ApsOps](https://github.com/ApsOps) +* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) +* **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484) +* **Prometheus**: Add $__interval, $__interval_ms, $__range, $__range_s & $__range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597) [#12882](https://github.com/grafana/grafana/issues/12882), thx [@roidelapluie](https://github.com/roidelapluie) +* **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda) +* **Postgres/MySQL/MSSQL**: New $__unixEpochGroup and $__unixEpochGroupAlias macros [#12892](https://github.com/grafana/grafana/issues/12892), thx [@svenklemm](https://github.com/svenklemm) +* **Postgres/MySQL/MSSQL**: Add previous fill mode to $__timeGroup macro which will fill in previously seen value when point is missing [#12756](https://github.com/grafana/grafana/issues/12756), thx [@svenklemm](https://github.com/svenklemm) +* **Postgres/MySQL/MSSQL**: Use floor rounding in $__timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm) +* **Postgres/MySQL/MSSQL**: Use metric column as prefix when returning multiple value columns [#12727](https://github.com/grafana/grafana/issues/12727), thx [@svenklemm](https://github.com/svenklemm) +* **Postgres/MySQL/MSSQL**: New $__timeGroupAlias macro. Postgres $__timeGroup no longer automatically adds time column alias [#12749](https://github.com/grafana/grafana/issues/12749), thx [@svenklemm](https://github.com/svenklemm) +* **Postgres/MySQL/MSSQL**: Escape single quotes in variables [#12785](https://github.com/grafana/grafana/issues/12785), thx [@eMerzh](https://github.com/eMerzh) +* **MySQL/MSSQL**: Use datetime format instead of epoch for $__timeFilter, $__timeFrom and $__timeTo macros [#11618](https://github.com/grafana/grafana/issues/11618) [#11619](https://github.com/grafana/grafana/issues/11619), thx [@AustinWinstanley](https://github.com/AustinWinstanley) +* **Postgres**: Escape ssl mode parameter in connectionstring [#12644](https://github.com/grafana/grafana/issues/12644), thx [@yogyrahmawan](https://github.com/yogyrahmawan) +* **Github OAuth**: Allow changes of user info at Github to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber) +* **Alerting**: Fix diff and percent_diff reducers [#11563](https://github.com/grafana/grafana/issues/11563), thx [@jessetane](https://github.com/jessetane) +* **Alerting**: Fix rendering timeout which could cause notifications to not be sent due to rendering timing out [#12151](https://github.com/grafana/grafana/issues/12151) +* **Cloudwatch**: Improved error handling [#12489](https://github.com/grafana/grafana/issues/12489), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: AppSync metrics and dimensions [#12300](https://github.com/grafana/grafana/issues/12300), thx [@franciscocpg](https://github.com/franciscocpg) +* **Cloudwatch**: Direct Connect metrics and dimensions [#12762](https://github.com/grafana/grafana/pulls/12762), thx [@mindriot88](https://github.com/mindriot88) +* **Cloudwatch**: Added BurstBalance metric to list of AWS RDS metrics [#12561](https://github.com/grafana/grafana/pulls/12561), thx [@activeshadow](https://github.com/activeshadow) +* **Cloudwatch**: Add new Redshift metrics and dimensions [#12063](https://github.com/grafana/grafana/pulls/12063), thx [@A21z](https://github.com/A21z) +* **Table**: Adjust header contrast for the light theme [#12668](https://github.com/grafana/grafana/issues/12668) +* **Table**: Fix link color when using light theme and thresholds in use [#12766](https://github.com/grafana/grafana/issues/12766) +om/grafana/grafana/issues/12668) +* **Table**: Fix for useless horizontal scrollbar for table panel [#9964](https://github.com/grafana/grafana/issues/9964) +* **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2) +* **Elasticsearch**: For alerting/backend, support having index name to the right of pattern in index pattern [#12731](https://github.com/grafana/grafana/issues/12731) +* **OAuth**: Fix overriding tls_skip_verify_insecure using environment variable [#12747](https://github.com/grafana/grafana/issues/12747), thx [@jangaraj](https://github.com/jangaraj) +* **Units**: Change units to include characters for power of 2 and 3 [#12744](https://github.com/grafana/grafana/pull/12744), thx [@Worty](https://github.com/Worty) +* **Units**: Polish złoty currency [#12691](https://github.com/grafana/grafana/pull/12691), thx [@mwegrzynek](https://github.com/mwegrzynek) +* **Graph**: Option to hide series from tooltip [#3341](https://github.com/grafana/grafana/issues/3341), thx [@mtanda](https://github.com/mtanda) +* **UI**: Fix iOS home screen "app" icon and Windows 10 app experience [#12752](https://github.com/grafana/grafana/issues/12752), thx [@andig](https://github.com/andig) +* **Datasource**: Fix UI issue with secret fields after updating datasource [#11270](https://github.com/grafana/grafana/issues/11270) +* **Plugins**: Convert URL-like text to links in plugins readme [#12843](https://github.com/grafana/grafana/pull/12843), thx [pgiraud](https://github.com/pgiraud) +* **Docker**: Make it possible to set a specific plugin url [#12861](https://github.com/grafana/grafana/pull/12861), thx [ClementGautier](https://github.com/ClementGautier) +* **Graphite**: Fix for quoting of int function parameters (when using variables) [#11927](https://github.com/grafana/grafana/pull/11927) +* **InfluxDB**: Support timeFilter in query templating for InfluxDB [#12598](https://github.com/grafana/grafana/pull/12598), thx [kichristensen](https://github.com/kichristensen) +* **Provisioning**: Should allow one default datasource per organisation [#12229](https://github.com/grafana/grafana/issues/12229) + +### Breaking changes + +* Postgres datasource no longer automatically adds time column alias when using the $__timeGroup alias. However, there's code in place which should make this change backward compatible and shouldn't create any issues. + +### New experimental features + +These are new features that's still being worked on and are in an experimental phase. We incourage users to try these out and provide any feedback in related issue. + +* **Dashboard**: Auto fit dashboard panels to optimize space used for current TV / Monitor [#12768](https://github.com/grafana/grafana/issues/12768) + +### Tech + +* **Frontend**: Convert all Frontend Karma tests to Jest tests [#12224](https://github.com/grafana/grafana/issues/12224) + +# 5.2.2 (2018-07-25) + +### Minor + +* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) +* **Dashboard**: Dashboard links not updated when changing variables [#12506](https://github.com/grafana/grafana/issues/12506) +* **Postgres/MySQL/MSSQL**: Fix connection leak [#12636](https://github.com/grafana/grafana/issues/12636) [#9827](https://github.com/grafana/grafana/issues/9827) +* **Plugins**: Fix loading of external plugins [#12551](https://github.com/grafana/grafana/issues/12551) +* **Dashboard**: Remove unwanted scrollbars in embedded panels [#12589](https://github.com/grafana/grafana/issues/12589) +* **Prometheus**: Prevent error using $__interval_ms in query [#12533](https://github.com/grafana/grafana/pull/12533), thx [@mtanda](https://github.com/mtanda) # 5.2.1 (2018-06-29) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..f7e45893c38 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +# Golang build container +FROM golang:1.10 + +WORKDIR $GOPATH/src/github.com/grafana/grafana + +COPY Gopkg.toml Gopkg.lock ./ +COPY vendor vendor + +ARG DEP_ENSURE="" +RUN if [ ! -z "${DEP_ENSURE}" ]; then \ + go get -u github.com/golang/dep/cmd/dep && \ + dep ensure --vendor-only; \ + fi + +COPY pkg pkg +COPY build.go build.go +COPY package.json package.json + +RUN go run build.go build + +# Node build container +FROM node:8 + +WORKDIR /usr/src/app/ + +COPY package.json yarn.lock ./ +RUN yarn install --pure-lockfile --no-progress + +COPY Gruntfile.js tsconfig.json tslint.json ./ +COPY public public +COPY scripts scripts +COPY emails emails + +ENV NODE_ENV production +RUN ./node_modules/.bin/grunt build + +# Final container +FROM debian:stretch-slim + +ARG GF_UID="472" +ARG GF_GID="472" + +ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + +WORKDIR $GF_PATHS_HOME + +RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +COPY conf ./conf + +RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ + groupadd -r -g $GF_GID grafana && \ + useradd -r -u $GF_UID -g grafana grafana && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ + cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ + chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" && \ + chmod 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" + +COPY --from=0 /go/src/github.com/grafana/grafana/bin/linux-amd64/grafana-server /go/src/github.com/grafana/grafana/bin/linux-amd64/grafana-cli ./bin/ +COPY --from=1 /usr/src/app/public ./public +COPY --from=1 /usr/src/app/tools ./tools +COPY tools/phantomjs/render.js ./tools/phantomjs/render.js + +EXPOSE 3000 + +COPY ./packaging/docker/run.sh /run.sh + +USER grafana +ENTRYPOINT [ "/run.sh" ] diff --git a/Gopkg.lock b/Gopkg.lock index 5acaf2a542c..6f08e208ecd 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -32,6 +32,7 @@ "aws/credentials/ec2rolecreds", "aws/credentials/endpointcreds", "aws/credentials/stscreds", + "aws/csm", "aws/defaults", "aws/ec2metadata", "aws/endpoints", @@ -43,6 +44,8 @@ "internal/shareddefaults", "private/protocol", "private/protocol/ec2query", + "private/protocol/eventstream", + "private/protocol/eventstream/eventstreamapi", "private/protocol/query", "private/protocol/query/queryutil", "private/protocol/rest", @@ -54,8 +57,8 @@ "service/s3", "service/sts" ] - revision = "c7cd1ebe87257cde9b65112fc876b0339ea0ac30" - version = "v1.13.49" + revision = "fde4ded7becdeae4d26bf1212916aabba79349b4" + version = "v1.14.12" [[projects]] branch = "master" @@ -424,6 +427,12 @@ revision = "1744e2970ca51c86172c8190fadad617561ed6e7" version = "v1.0.0" +[[projects]] + branch = "master" + name = "github.com/shurcooL/sanitized_anchor_name" + packages = ["."] + revision = "86672fcb3f950f35f2e675df2240550f2a50762f" + [[projects]] name = "github.com/smartystreets/assertions" packages = [ @@ -670,6 +679,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "85cc057e0cc074ab5b43bd620772d63d51e07b04e8782fcfe55e6929d2fc40f7" + inputs-digest = "cb8e7fd81f23ec987fc4d5dd9d31ae0f1164bc2f30cbea2fe86e0d97dd945beb" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 1768059f0b8..6c91ec37221 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -36,7 +36,7 @@ ignored = [ [[constraint]] name = "github.com/aws/aws-sdk-go" - version = "1.12.65" + version = "1.13.56" [[constraint]] branch = "master" diff --git a/Gruntfile.js b/Gruntfile.js index 23276e8a122..8a71fb44148 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,4 +1,3 @@ -/* jshint node:true */ 'use strict'; module.exports = function (grunt) { var os = require('os'); diff --git a/Makefile b/Makefile index c1d755d247d..c6915409ed7 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,15 @@ build-js: build: build-go build-js +build-docker-dev: + @echo "\033[92mInfo:\033[0m the frontend code is expected to be built already." + go run build.go -goos linux -pkg-arch amd64 ${OPT} build package-only latest + cp dist/grafana-latest.linux-x64.tar.gz packaging/docker + cd packaging/docker && docker build --tag grafana/grafana:dev . + +build-docker-full: + docker build --tag grafana/grafana:dev . + test-go: go test -v ./pkg/... @@ -36,4 +45,4 @@ run: ./bin/grafana-server protoc: - protoc -I pkg/tsdb/models pkg/tsdb/models/*.proto --go_out=plugins=grpc:pkg/tsdb/models/. \ No newline at end of file + protoc -I pkg/tsdb/models pkg/tsdb/models/*.proto --go_out=plugins=grpc:pkg/tsdb/models/. diff --git a/NOTICE.md b/NOTICE.md index ca148971b62..899b2a3c3f9 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,5 +1,5 @@ -Copyright 2014-2017 Grafana Labs +Copyright 2014-2018 Grafana Labs This software is based on Kibana: Copyright 2012-2013 Elasticsearch BV diff --git a/README.md b/README.md index 322523d703b..74fb10c8066 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ To build the assets, rebuild on file change, and serve them by Grafana's webserv ```bash npm install -g yarn yarn install --pure-lockfile -npm run watch +yarn watch ``` Build the assets, rebuild on file change with Hot Module Replacement (HMR), and serve them by webpack-dev-server (http://localhost:3333): @@ -54,14 +54,9 @@ env GRAFANA_THEME=light yarn start ``` Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload. -Run tests +Run tests ```bash -npm run jest -``` - -Run karma tests -```bash -npm run karma +yarn jest ``` ### Recompile backend on source change @@ -74,6 +69,15 @@ bra run Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`). +### Building a docker image (on linux/amd64) + +This builds a docker image from your local sources: + +1. Build the frontend `go run build.go build-frontend` +2. Build the docker image `make build-docker-dev` + +The resulting image will be tagged as `grafana/grafana:dev` + ### Dev config Create a custom.ini in the conf directory to override default configuration options. @@ -89,30 +93,38 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = #### Frontend Execute all frontend tests ```bash -npm run test +yarn test ``` -Writing & watching frontend tests (we have two test runners) +Writing & watching frontend tests -- jest for all new tests that do not require browser context (React+more) - - Start watcher: `npm run jest` - - Jest will run all test files that end with the name ".jest.ts" -- karma + mocha is used for testing angularjs components. We do want to migrate these test to jest over time (if possible). - - Start watcher: `npm run karma` - - Karma+Mocha runs all files that end with the name "_specs.ts". +- Start watcher: `yarn jest` +- Jest will run all test files that end with the name ".test.ts" #### Backend ```bash # Run Golang tests using sqlite3 as database (default) -go test ./pkg/... +go test ./pkg/... # Run Golang tests using mysql as database - convenient to use /docker/blocks/mysql_tests -GRAFANA_TEST_DB=mysql go test ./pkg/... +GRAFANA_TEST_DB=mysql go test ./pkg/... # Run Golang tests using postgres as database - convenient to use /docker/blocks/postgres_tests -GRAFANA_TEST_DB=postgres go test ./pkg/... +GRAFANA_TEST_DB=postgres go test ./pkg/... ``` +## Building custom docker image + +You can build a custom image using Docker, which doesn't require installing any dependencies besides docker itself. +```bash +git clone https://github.com/grafana/grafana +cd grafana +docker build -t grafana:dev . +docker run -d --name=grafana -p 3000:3000 grafana:dev +``` + +Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`). + ## Contribute If you have any idea for an improvement or found a bug, do not hesitate to open an issue. diff --git a/ROADMAP.md b/ROADMAP.md index 6f8111fd2d4..891bc9f790b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ -# Roadmap (2018-06-26) +# Roadmap (2018-08-07) This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. But it will give you an idea of our current vision and plan. ### Short term (1-2 months) + - PRs & Bugs - Multi-Stat panel - Metrics & Log Explore UI @@ -11,17 +12,16 @@ But it will give you an idea of our current vision and plan. - React Panels - Change visualization (panel type) on the fly. - Templating Query Editor UI Plugin hook + - Backend plugins ### Long term (4 - 8 months) - -- Alerting improvements (silence, per series tracking, etc) -- Progress on React migration + - Alerting improvements (silence, per series tracking, etc) + - Progress on React migration ### In a distant future far far away - -- Meta queries -- Integrated light weight TSDB -- Web socket & live data sources + - Meta queries + - Integrated light weight TSDB + - Web socket & live data sources ### Outside contributions We know this is being worked on right now by contributors (and we hope to merge it when it's ready). diff --git a/build.go b/build.go index 3f92f8833a2..bcb9b2ddf7d 100644 --- a/build.go +++ b/build.go @@ -330,6 +330,7 @@ func createPackage(options linuxPackageOptions) { name := "grafana" if enterprise { name += "-enterprise" + args = append(args, "--replaces", "grafana") } args = append(args, "--name", name) @@ -465,7 +466,6 @@ func ldflags() string { b.WriteString(fmt.Sprintf(" -X main.version=%s", version)) b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha())) b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp())) - b.WriteString(fmt.Sprintf(" -X main.enterprise=%t", enterprise)) return b.String() } diff --git a/conf/defaults.ini b/conf/defaults.ini index 5faba3ea7bd..90fc144c6e0 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -213,6 +213,9 @@ allow_org_create = false # Set to true to automatically assign new users to the default organization (id 1) auto_assign_org = true +# Set this value to automatically add new users to the provided organization (if auto_assign_org above is set to true) +auto_assign_org_id = 1 + # Default role new users will be automatically assigned (if auto_assign_org above is set to true) auto_assign_org_role = Viewer @@ -267,6 +270,18 @@ api_url = https://api.github.com/user team_ids = allowed_organizations = +#################################### GitLab Auth ######################### +[auth.gitlab] +enabled = false +allow_sign_up = true +client_id = some_id +client_secret = some_secret +scopes = api +auth_url = https://gitlab.com/oauth/authorize +token_url = https://gitlab.com/oauth/token +api_url = https://gitlab.com/api/v4 +allowed_groups = + #################################### Google Auth ######################### [auth.google] enabled = false @@ -311,6 +326,10 @@ token_url = api_url = team_ids = allowed_organizations = +tls_skip_verify_insecure = false +tls_client_cert = +tls_client_key = +tls_client_ca = #################################### Basic Auth ########################## [auth.basic] diff --git a/conf/ldap.toml b/conf/ldap.toml index 166d85eabb1..9a7088ed823 100644 --- a/conf/ldap.toml +++ b/conf/ldap.toml @@ -15,6 +15,9 @@ start_tls = false ssl_skip_verify = false # set to the path to your root CA certificate or leave unset to use system defaults # root_ca_cert = "/path/to/certificate.crt" +# Authentication against LDAP servers requiring client certificates +# client_cert = "/path/to/client.crt" +# client_key = "/path/to/client.key" # Search user bind dn bind_dn = "cn=admin,dc=grafana,dc=org" @@ -72,6 +75,8 @@ email = "email" [[servers.group_mappings]] group_dn = "cn=admins,dc=grafana,dc=org" org_role = "Admin" +# To make user an instance admin (Grafana Admin) uncomment line below +# grafana_admin = true # The Grafana organization database id, optional, if left out the default org (id 1) will be used # org_id = 1 diff --git a/conf/sample.ini b/conf/sample.ini index 87544a5ac39..4291071e026 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -272,6 +272,10 @@ log_queries = ;api_url = https://foo.bar/user ;team_ids = ;allowed_organizations = +;tls_skip_verify_insecure = false +;tls_client_cert = +;tls_client_key = +;tls_client_ca = #################################### Grafana.com Auth #################### [auth.grafana_com] diff --git a/devenv/README.md b/devenv/README.md index 4ec6f672f25..9abf3596776 100644 --- a/devenv/README.md +++ b/devenv/README.md @@ -1,11 +1,16 @@ This folder contains useful scripts and configuration for... -* Configuring datasources in Grafana -* Provision example dashboards in Grafana -* Run preconfiured datasources as docker containers - -want to know more? run setup! +* Configuring dev datasources in Grafana +* Configuring dev & test scenarios dashboards. ```bash ./setup.sh ``` + +After restarting grafana server there should now be a number of datasources named `gdev-` provisioned as well as a dashboard folder named `gdev dashboards`. This folder contains dashboard & panel features tests dashboards. + +# Dev dashboards + +Please update these dashboards or make new ones as new panels & dashboards features are developed or new bugs are found. The dashboards are located in the `devenv/dev-dashboards` folder. + + diff --git a/devenv/dashboards/bulk-testing/bulk-dashboards.yaml b/devenv/bulk-dashboards/bulk-dashboards.yaml similarity index 100% rename from devenv/dashboards/bulk-testing/bulk-dashboards.yaml rename to devenv/bulk-dashboards/bulk-dashboards.yaml diff --git a/devenv/dashboards/bulk-testing/bulkdash.jsonnet b/devenv/bulk-dashboards/bulkdash.jsonnet similarity index 100% rename from devenv/dashboards/bulk-testing/bulkdash.jsonnet rename to devenv/bulk-dashboards/bulkdash.jsonnet diff --git a/devenv/dashboards.yaml b/devenv/dashboards.yaml new file mode 100644 index 00000000000..226c1a8b335 --- /dev/null +++ b/devenv/dashboards.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: 'gdev dashboards' + folder: 'gdev dashboards' + type: file + options: + path: devenv/dev-dashboards + diff --git a/devenv/dashboards/dev-dashboards/dashboard_with_rows.json b/devenv/dashboards/dev-dashboards/dashboard_with_rows.json deleted file mode 100644 index 335c27bc80a..00000000000 --- a/devenv/dashboards/dev-dashboards/dashboard_with_rows.json +++ /dev/null @@ -1,592 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": 59, - "links": [], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 9, - "panels": [], - "title": "Row title", - "type": "row" - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 4, - "w": 12, - "x": 0, - "y": 1 - }, - "id": 12, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 4, - "w": 12, - "x": 12, - "y": 1 - }, - "id": 5, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 5 - }, - "id": 7, - "panels": [], - "title": "Row", - "type": "row" - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 4, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 4, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 13, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 11, - "panels": [], - "title": "Row title", - "type": "row" - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 4, - "w": 12, - "x": 0, - "y": 11 - }, - "id": 4, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "Prometheus", - "fill": 1, - "gridPos": { - "h": 4, - "w": 12, - "x": 12, - "y": 11 - }, - "id": 3, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "expr": "go_goroutines", - "format": "time_series", - "intervalFactor": 1, - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Panel Title", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - } - ], - "schemaVersion": 16, - "style": "dark", - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-30m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "", - "title": "Dashboard with rows", - "uid": "1DdOzBNmk", - "version": 5 -} diff --git a/devenv/dashboards/dev-dashboards/dev-dashboards.yaml b/devenv/dashboards/dev-dashboards/dev-dashboards.yaml deleted file mode 100644 index 343910de738..00000000000 --- a/devenv/dashboards/dev-dashboards/dev-dashboards.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: 1 - -providers: - - name: 'dev dashboards' - folder: 'dev dashboards' - type: file - options: - path: devenv/dashboards/dev-dashboards - diff --git a/devenv/datasources/default/default.yaml b/devenv/datasources.yaml similarity index 55% rename from devenv/datasources/default/default.yaml rename to devenv/datasources.yaml index dc2310f15aa..a4e9bf05641 100644 --- a/devenv/datasources/default/default.yaml +++ b/devenv/datasources.yaml @@ -1,38 +1,41 @@ apiVersion: 1 datasources: - - name: Graphite + - name: gdev-graphite type: graphite access: proxy url: http://localhost:8080 jsonData: graphiteVersion: "1.1" - - - name: Prometheus + + - name: gdev-prometheus type: prometheus access: proxy isDefault: true url: http://localhost:9090 - - - name: InfluxDB + + - name: gdev-testdata + type: testdata + + - name: gdev-influxdb type: influxdb access: proxy database: site user: grafana password: grafana url: http://localhost:8086 - jsonData: + jsonData: timeInterval: "15s" - - name: OpenTsdb + - name: gdev-opentsdb type: opentsdb access: proxy url: http://localhost:4242 - jsonData: + jsonData: tsdbResolution: 1 tsdbVersion: 1 - - name: Elastic + - name: gdev-elasticsearch-metrics type: elasticsearch access: proxy database: "[metrics-]YYYY.MM.DD" @@ -40,34 +43,62 @@ datasources: jsonData: interval: Daily timeField: "@timestamp" - - - name: MySQL + + - name: gdev-mysql type: mysql url: localhost:3306 database: grafana user: grafana password: password - - name: MSSQL + - name: gdev-mysql-ds-tests + type: mysql + url: localhost:3306 + database: grafana_ds_tests + user: grafana + password: password + + - name: gdev-mssql type: mssql url: localhost:1433 database: grafana user: grafana - password: "Password!" + secureJsonData: + password: Password! - - name: Postgres + - name: gdev-mssql-ds-tests + type: mssql + url: localhost:1433 + database: grafanatest + user: grafana + secureJsonData: + password: Password! + + - name: gdev-postgres type: postgres url: localhost:5432 database: grafana user: grafana - password: password + secureJsonData: + password: password jsonData: sslmode: "disable" - - name: Cloudwatch + - name: gdev-postgres-ds-tests + type: postgres + url: localhost:5432 + database: grafanadstest + user: grafanatest + secureJsonData: + password: grafanatest + jsonData: + sslmode: "disable" + + - name: gdev-cloudwatch type: cloudwatch editable: true jsonData: authType: credentials defaultRegion: eu-west-2 + diff --git a/docker/blocks/mssql/dashboard.json b/devenv/dev-dashboards/datasource_tests_mssql_fakedata.json similarity index 92% rename from docker/blocks/mssql/dashboard.json rename to devenv/dev-dashboards/datasource_tests_mssql_fakedata.json index ce9aa141a75..e810a686134 100644 --- a/docker/blocks/mssql/dashboard.json +++ b/devenv/dev-dashboards/datasource_tests_mssql_fakedata.json @@ -1,40 +1,4 @@ { - "__inputs": [ - { - "name": "DS_MSSQL", - "label": "MSSQL", - "description": "", - "type": "datasource", - "pluginId": "mssql", - "pluginName": "MSSQL" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "5.0.0" - }, - { - "type": "datasource", - "id": "mssql", - "name": "MSSQL", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "5.0.0" - } - ], "annotations": { "list": [ { @@ -52,8 +16,7 @@ "editable": true, "gnetId": null, "graphTooltip": 0, - "id": null, - "iteration": 1520976748896, + "iteration": 1532618661457, "links": [], "panels": [ { @@ -63,7 +26,7 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL}", + "datasource": "gdev-mssql", "fill": 2, "gridPos": { "h": 9, @@ -149,14 +112,18 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL}", + "datasource": "gdev-mssql", "fill": 2, "gridPos": { "h": 18, @@ -234,14 +201,18 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL}", + "datasource": "gdev-mssql", "fill": 2, "gridPos": { "h": 9, @@ -313,11 +284,15 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "columns": [], - "datasource": "${DS_MSSQL}", + "datasource": "gdev-mssql", "fontSize": "100%", "gridPos": { "h": 10, @@ -371,13 +346,13 @@ ], "schemaVersion": 16, "style": "dark", - "tags": [], + "tags": ["gdev", "mssql", "fake-data-gen"], "templating": { "list": [ { "allValue": null, "current": {}, - "datasource": "${DS_MSSQL}", + "datasource": "gdev-mssql", "hide": 0, "includeAll": false, "label": "Datacenter", @@ -387,6 +362,7 @@ "query": "SELECT DISTINCT datacenter FROM grafana_metric", "refresh": 1, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -397,7 +373,7 @@ { "allValue": null, "current": {}, - "datasource": "${DS_MSSQL}", + "datasource": "gdev-mssql", "hide": 0, "includeAll": true, "label": "Hostname", @@ -407,6 +383,7 @@ "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", "refresh": 1, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -499,6 +476,7 @@ ], "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", "refresh": 2, + "skipUrlSync": false, "type": "interval" } ] @@ -533,7 +511,7 @@ ] }, "timezone": "", - "title": "Grafana Fake Data Gen - MSSQL", + "title": "Datasource tests - MSSQL", "uid": "86Js1xRmk", - "version": 11 + "version": 1 } \ No newline at end of file diff --git a/docker/blocks/mysql_tests/dashboard.json b/devenv/dev-dashboards/datasource_tests_mssql_unittest.json similarity index 70% rename from docker/blocks/mysql_tests/dashboard.json rename to devenv/dev-dashboards/datasource_tests_mssql_unittest.json index 53f313315bd..b2d757ae188 100644 --- a/docker/blocks/mysql_tests/dashboard.json +++ b/devenv/dev-dashboards/datasource_tests_mssql_unittest.json @@ -1,40 +1,4 @@ { - "__inputs": [ - { - "name": "DS_MYSQL_TEST", - "label": "MySQL TEST", - "description": "", - "type": "datasource", - "pluginId": "mysql", - "pluginName": "MySQL" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "5.0.0" - }, - { - "type": "datasource", - "id": "mysql", - "name": "MySQL", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "5.0.0" - } - ], "annotations": { "list": [ { @@ -47,31 +11,31 @@ "type": "dashboard" }, { - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "enable": false, "hide": false, "iconColor": "#6ed0e0", "limit": 100, "name": "Deploys", - "rawQuery": "SELECT\n time_sec,\n description as text,\n tags\n FROM event\n WHERE $__unixEpochFilter(time_sec) AND tags='deploy'\n ORDER BY 1 ASC\n ", + "rawQuery": "SELECT\n $__time(time_sec),\n description as [text],\n tags\n FROM [event]\n WHERE $__unixEpochFilter(time_sec) AND tags='deploy'\n ORDER BY 1 ASC\n ", "showIn": 0, "tags": [], "type": "tags" }, { - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "enable": false, "hide": false, "iconColor": "rgba(255, 96, 96, 1)", "limit": 100, "name": "Tickets", - "rawQuery": "SELECT\n time_sec as time,\n description as text,\n tags\n FROM event\n WHERE $__unixEpochFilter(time_sec) AND tags='ticket'\n ORDER BY 1 ASC\n ", + "rawQuery": "SELECT\n $__time(time_sec),\n description as [text],\n tags\n FROM [event]\n WHERE $__unixEpochFilter(time_sec) AND tags='ticket'\n ORDER BY 1 ASC\n ", "showIn": 0, "tags": [], "type": "tags" }, { - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "enable": false, "hide": false, "iconColor": "#7eb26d", @@ -83,7 +47,7 @@ "type": "tags" }, { - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "enable": false, "hide": false, "iconColor": "#1f78c1", @@ -96,16 +60,16 @@ } ] }, + "description": "Run the mssql unit tests to generate the data backing this dashboard", "editable": true, "gnetId": null, "graphTooltip": 0, - "id": null, - "iteration": 1523320712115, + "iteration": 1534507501976, "links": [], "panels": [ { "columns": [], - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fontSize": "100%", "gridPos": { "h": 4, @@ -142,7 +106,7 @@ { "alias": "", "format": "table", - "rawSql": "SELECT * from mysql_types", + "rawSql": "SELECT * from mssql_types", "refId": "A" } ], @@ -152,7 +116,7 @@ }, { "columns": [], - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -173,7 +137,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time_sec", + "pattern": "time", "type": "date" }, { @@ -195,18 +159,18 @@ { "alias": "", "format": "table", - "rawSql": "SELECT cast(null as unsigned integer) as time_sec", + "rawSql": "SELECT cast(null as bigint) as time", "refId": "A", "target": "" } ], - "title": "cast(null as unsigned integer) as time", + "title": "cast(null as bigint) as time", "transform": "table", "type": "table" }, { "columns": [], - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -227,7 +191,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time_sec", + "pattern": "time", "type": "date" }, { @@ -249,7 +213,7 @@ { "alias": "", "format": "table", - "rawSql": "SELECT cast(null as datetime) as time_sec", + "rawSql": "SELECT cast(null as datetime) as time", "refId": "A", "target": "" } @@ -260,7 +224,7 @@ }, { "columns": [], - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -281,7 +245,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time_sec", + "pattern": "time", "type": "date" }, { @@ -303,18 +267,18 @@ { "alias": "", "format": "table", - "rawSql": "SELECT cast(NOW() as datetime) as time_sec", + "rawSql": "SELECT GETDATE() as time", "refId": "A", "target": "" } ], - "title": "cast()NOW() as datetime) as time", + "title": "GETDATE() as time", "transform": "table", "type": "table" }, { "columns": [], - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -357,12 +321,12 @@ { "alias": "", "format": "table", - "rawSql": "SELECT NOW() as time", + "rawSql": "SELECT GETUTCDATE() as time", "refId": "A", "target": "" } ], - "title": "NOW() as time", + "title": "GETUTCDATE() as time", "transform": "table", "type": "table" }, @@ -371,11 +335,11 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, + "h": 6, + "w": 6, "x": 0, "y": 7 }, @@ -405,7 +369,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", "refId": "A" } ], @@ -454,12 +418,12 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 8, + "h": 6, + "w": 6, + "x": 6, "y": 7 }, "id": 9, @@ -488,7 +452,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m', NULL) AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', NULL), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", "refId": "A" } ], @@ -537,12 +501,12 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 16, + "h": 6, + "w": 6, + "x": 12, "y": 7 }, "id": 10, @@ -571,7 +535,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m', 10.0) AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', 10.0), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", "refId": "A" } ], @@ -615,18 +579,101 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mssql-ds-tests", + "fill": 2, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 7 + }, + "id": 36, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 3, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', previous), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "timeGroup macro 5m with fill(previous) and null as zero", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, + "h": 6, + "w": 6, "x": 0, - "y": 16 + "y": 13 }, "id": 16, "legend": { @@ -654,7 +701,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize') AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", "refId": "A" } ], @@ -703,13 +750,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 16 + "h": 6, + "w": 6, + "x": 6, + "y": 13 }, "id": 12, "legend": { @@ -737,7 +784,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize', NULL) AS time, sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', NULL), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", "refId": "A" } ], @@ -786,13 +833,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 16, - "y": 16 + "h": 6, + "w": 6, + "x": 12, + "y": 13 }, "id": 13, "legend": { @@ -820,7 +867,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize', 100.0) AS time, sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', 100.0), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", "refId": "A" } ], @@ -864,18 +911,101 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mssql-ds-tests", + "fill": 2, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 13 + }, + "id": 37, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', previous), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Metrics - timeGroup macro $summarize with fill(previous)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 25 + "y": 19 }, "id": 27, "legend": { @@ -907,14 +1037,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize') as time, \n CONCAT(measurement, ' - value one') as metric, \n avg(valueOne) as valueOne\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement IN($metric)\nGROUP BY 1, 2\nORDER BY 1", + "rawSql": "SELECT \n $__timeGroupAlias(time, '$summarize'), \n measurement as metric, \n avg(valueOne) as valueOne,\n avg(valueTwo) as valueTwo\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__timeGroup(time, '$summarize'), \n measurement \nORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize') as time, \n CONCAT(measurement, ' - value two') as metric, \n avg(valueTwo) as valueTwo \nFROM\n metric_values\nWHERE\n $__timeFilter(time) AND\n measurement IN($metric)\nGROUP BY 1,2\nORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -962,13 +1086,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 25 + "y": 19 }, "id": 5, "legend": { @@ -1010,8 +1134,14 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize') as time, \n avg(valueOne) as valueOne, \n avg(valueTwo) as valueTwo \nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n measurement IN($metric)\nGROUP BY 1\nORDER BY 1", + "rawSql": "SELECT \n $__timeGroupAlias(time, '$summarize'), \n avg(valueOne) as valueOne, \n avg(valueTwo) as valueTwo \nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__timeGroup(time, '$summarize')\nORDER BY 1", "refId": "A" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n time,\n avg(valueOne) OVER (ORDER BY time ROWS BETWEEN 6 PRECEDING AND 6 FOLLOWING) as MovingAverageValueOne,\n avg(valueTwo) OVER (ORDER BY time ROWS BETWEEN 6 PRECEDING AND 6 FOLLOWING) as MovingAverageValueTwo\nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n ($metric = 'ALL' OR measurement = $metric)\nORDER BY 1", + "refId": "B" } ], "thresholds": [], @@ -1059,13 +1189,203 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 33 + "y": 27 + }, + "id": 38, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n $__unixEpochGroupAlias(timeInt32, '$summarize'), \n measurement as metric, \n avg(valueOne) as valueOne,\n avg(valueTwo) as valueTwo\nFROM\n metric_values \nWHERE\n $__unixEpochFilter(timeInt32) AND\n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__unixEpochGroup(timeInt32, '$summarize'), \n measurement \nORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column using unixEpochGroup macro ($summarize)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mssql-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 39, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "MovingAverageValueOne", + "dashes": true, + "lines": false + }, + { + "alias": "MovingAverageValueTwo", + "dashes": true, + "lines": false, + "yaxis": 1 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n $__unixEpochGroupAlias(timeInt32, '$summarize'), \n avg(valueOne) as valueOne,\n avg(valueTwo) as valueTwo\nFROM\n metric_values \nWHERE\n $__unixEpochFilter(timeInt32) AND\n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__unixEpochGroup(timeInt32, '$summarize')\nORDER BY 1", + "refId": "A" + }, + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n time,\n avg(valueOne) OVER (ORDER BY time ROWS BETWEEN 6 PRECEDING AND 6 FOLLOWING) as MovingAverageValueOne,\n avg(valueTwo) OVER (ORDER BY time ROWS BETWEEN 6 PRECEDING AND 6 FOLLOWING) as MovingAverageValueTwo\nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n ($metric = 'ALL' OR measurement = $metric)\nORDER BY 1", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column using unixEpochGroup macro ($summarize)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mssql-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 35 }, "id": 4, "legend": { @@ -1097,14 +1417,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__time(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1152,13 +1466,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 33 + "y": 35 }, "id": 28, "legend": { @@ -1188,7 +1502,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__time(time), valueOne, valueTwo FROM metric_values ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values ORDER BY 1", "refId": "A" } ], @@ -1237,13 +1551,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 41 + "y": 43 }, "id": 19, "legend": { @@ -1275,14 +1589,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__time(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1330,13 +1638,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 41 + "y": 43 }, "id": 18, "legend": { @@ -1366,7 +1674,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" } ], @@ -1415,13 +1723,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 49 + "y": 51 }, "id": 17, "legend": { @@ -1453,14 +1761,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__time(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1508,13 +1810,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 49 + "y": 51 }, "id": 20, "legend": { @@ -1544,7 +1846,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" } ], @@ -1588,18 +1890,192 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mssql-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 59 + }, + "id": 29, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "DECLARE\n @from int = $__unixEpochFrom(), \n @to int = $__unixEpochTo(), \n @interval nvarchar(50) = '$summarize', \n @metric nvarchar(200) = $metric\n \nEXEC dbo.sp_test_epoch @from, @to, @interval, @metric", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Stored procedure support using epoch", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mssql-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 59 + }, + "id": 30, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "DECLARE\n @from datetime = $__timeFrom(), \n @to datetime = $__timeTo(), \n @interval nvarchar(50) = '$summarize', \n @metric nvarchar(200) = $metric\n \nEXEC dbo.sp_test_datetime @from, @to, @interval, @metric", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Stored procedure support using datetime", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 57 + "y": 67 }, "id": 14, "legend": { @@ -1629,14 +2105,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1687,13 +2157,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 57 + "y": 67 }, "id": 15, "legend": { @@ -1723,7 +2193,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" } ], @@ -1774,13 +2244,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 65 + "y": 75 }, "id": 25, "legend": { @@ -1810,14 +2280,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1868,13 +2332,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 65 + "y": 75 }, "id": 22, "legend": { @@ -1904,7 +2368,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" } ], @@ -1955,13 +2419,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 73 + "y": 83 }, "id": 21, "legend": { @@ -1991,14 +2455,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2049,13 +2507,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 73 + "y": 83 }, "id": 26, "legend": { @@ -2085,7 +2543,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" } ], @@ -2136,13 +2594,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 81 + "y": 91 }, "id": 23, "legend": { @@ -2172,14 +2630,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), CONCAT(measurement, ' - value two') as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2230,13 +2682,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL_TEST}", + "datasource": "gdev-mssql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 81 + "y": 91 }, "id": 24, "legend": { @@ -2266,7 +2718,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", "refId": "A" } ], @@ -2316,22 +2768,47 @@ "refresh": false, "schemaVersion": 16, "style": "dark", - "tags": [], + "tags": [ + "gdev", + "mssql" + ], "templating": { "list": [ { - "allValue": "", - "current": {}, - "datasource": "${DS_MYSQL_TEST}", + "allValue": "'ALL'", + "current": { + "selected": true, + "tags": [], + "text": "All", + "value": "$__all" + }, + "datasource": "gdev-mssql-ds-tests", "hide": 0, "includeAll": true, "label": "Metric", - "multi": true, + "multi": false, "name": "metric", - "options": [], + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "Metric A", + "value": "Metric A" + }, + { + "selected": false, + "text": "Metric B", + "value": "Metric B" + } + ], "query": "SELECT DISTINCT measurement FROM metric_values", - "refresh": 1, + "refresh": 0, "regex": "", + "skipUrlSync": false, "sort": 0, "tagValuesQuery": "", "tags": [], @@ -2384,6 +2861,7 @@ ], "query": "1s,10s,30s,1m,5m,10m", "refresh": 2, + "skipUrlSync": false, "type": "interval" } ] @@ -2418,7 +2896,7 @@ ] }, "timezone": "", - "title": "MySQL Data Source Test", - "uid": "Hmf8FDkmz", - "version": 12 + "title": "Datasource tests - MSSQL (unit test)", + "uid": "GlAqcPgmz", + "version": 2 } \ No newline at end of file diff --git a/docker/blocks/mysql/dashboard.json b/devenv/dev-dashboards/datasource_tests_mysql_fakedata.json similarity index 92% rename from docker/blocks/mysql/dashboard.json rename to devenv/dev-dashboards/datasource_tests_mysql_fakedata.json index dba7847cc72..ebeb452fc4c 100644 --- a/docker/blocks/mysql/dashboard.json +++ b/devenv/dev-dashboards/datasource_tests_mysql_fakedata.json @@ -1,40 +1,4 @@ { - "__inputs": [ - { - "name": "DS_MYSQL", - "label": "MySQL", - "description": "", - "type": "datasource", - "pluginId": "mysql", - "pluginName": "MySQL" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "5.0.0" - }, - { - "type": "datasource", - "id": "mysql", - "name": "MySQL", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "5.0.0" - } - ], "annotations": { "list": [ { @@ -52,8 +16,7 @@ "editable": true, "gnetId": null, "graphTooltip": 0, - "id": null, - "iteration": 1523372133566, + "iteration": 1532620738041, "links": [], "panels": [ { @@ -63,7 +26,7 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL}", + "datasource": "gdev-mysql", "fill": 2, "gridPos": { "h": 9, @@ -161,7 +124,7 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL}", + "datasource": "gdev-mysql", "fill": 2, "gridPos": { "h": 18, @@ -251,7 +214,7 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MYSQL}", + "datasource": "gdev-mysql", "fill": 2, "gridPos": { "h": 9, @@ -332,7 +295,7 @@ }, { "columns": [], - "datasource": "${DS_MYSQL}", + "datasource": "gdev-mysql", "fontSize": "100%", "gridPos": { "h": 9, @@ -390,6 +353,7 @@ "schemaVersion": 16, "style": "dark", "tags": [ + "gdev", "fake-data-gen", "mysql" ], @@ -397,8 +361,11 @@ "list": [ { "allValue": null, - "current": {}, - "datasource": "${DS_MYSQL}", + "current": { + "text": "America", + "value": "America" + }, + "datasource": "gdev-mysql", "hide": 0, "includeAll": false, "label": "Datacenter", @@ -408,6 +375,7 @@ "query": "SELECT DISTINCT datacenter FROM grafana_metric", "refresh": 1, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -417,8 +385,11 @@ }, { "allValue": null, - "current": {}, - "datasource": "${DS_MYSQL}", + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "gdev-mysql", "hide": 0, "includeAll": true, "label": "Hostname", @@ -428,6 +399,7 @@ "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", "refresh": 1, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -520,6 +492,7 @@ ], "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", "refresh": 2, + "skipUrlSync": false, "type": "interval" } ] @@ -554,7 +527,7 @@ ] }, "timezone": "", - "title": "Grafana Fake Data Gen - MySQL", + "title": "Datasource tests - MySQL", "uid": "DGsCac3kz", "version": 8 } \ No newline at end of file diff --git a/docker/blocks/mssql_tests/dashboard.json b/devenv/dev-dashboards/datasource_tests_mysql_unittest.json similarity index 81% rename from docker/blocks/mssql_tests/dashboard.json rename to devenv/dev-dashboards/datasource_tests_mysql_unittest.json index 80994254093..0255f3c0c91 100644 --- a/docker/blocks/mssql_tests/dashboard.json +++ b/devenv/dev-dashboards/datasource_tests_mysql_unittest.json @@ -1,40 +1,4 @@ { - "__inputs": [ - { - "name": "DS_MSSQL_TEST", - "label": "MSSQL Test", - "description": "", - "type": "datasource", - "pluginId": "mssql", - "pluginName": "Microsoft SQL Server" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "5.0.0" - }, - { - "type": "datasource", - "id": "mssql", - "name": "Microsoft SQL Server", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "5.0.0" - } - ], "annotations": { "list": [ { @@ -47,31 +11,31 @@ "type": "dashboard" }, { - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "enable": false, "hide": false, "iconColor": "#6ed0e0", "limit": 100, "name": "Deploys", - "rawQuery": "SELECT\n $__time(time_sec),\n description as [text],\n tags\n FROM [event]\n WHERE $__unixEpochFilter(time_sec) AND tags='deploy'\n ORDER BY 1 ASC\n ", + "rawQuery": "SELECT\n time_sec,\n description as text,\n tags\n FROM event\n WHERE $__unixEpochFilter(time_sec) AND tags='deploy'\n ORDER BY 1 ASC\n ", "showIn": 0, "tags": [], "type": "tags" }, { - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "enable": false, "hide": false, "iconColor": "rgba(255, 96, 96, 1)", "limit": 100, "name": "Tickets", - "rawQuery": "SELECT\n $__time(time_sec),\n description as [text],\n tags\n FROM [event]\n WHERE $__unixEpochFilter(time_sec) AND tags='ticket'\n ORDER BY 1 ASC\n ", + "rawQuery": "SELECT\n time_sec as time,\n description as text,\n tags\n FROM event\n WHERE $__unixEpochFilter(time_sec) AND tags='ticket'\n ORDER BY 1 ASC\n ", "showIn": 0, "tags": [], "type": "tags" }, { - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "enable": false, "hide": false, "iconColor": "#7eb26d", @@ -83,7 +47,7 @@ "type": "tags" }, { - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "enable": false, "hide": false, "iconColor": "#1f78c1", @@ -96,16 +60,16 @@ } ] }, + "description": "Run the mysql unit tests to generate the data backing this dashboard", "editable": true, "gnetId": null, "graphTooltip": 0, - "id": null, - "iteration": 1523320861623, + "iteration": 1534508678095, "links": [], "panels": [ { "columns": [], - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fontSize": "100%", "gridPos": { "h": 4, @@ -142,7 +106,7 @@ { "alias": "", "format": "table", - "rawSql": "SELECT * from mssql_types", + "rawSql": "SELECT * from mysql_types", "refId": "A" } ], @@ -152,7 +116,7 @@ }, { "columns": [], - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -173,7 +137,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time", + "pattern": "time_sec", "type": "date" }, { @@ -195,18 +159,18 @@ { "alias": "", "format": "table", - "rawSql": "SELECT cast(null as bigint) as time", + "rawSql": "SELECT cast(null as unsigned integer) as time_sec", "refId": "A", "target": "" } ], - "title": "cast(null as bigint) as time", + "title": "cast(null as unsigned integer) as time", "transform": "table", "type": "table" }, { "columns": [], - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -227,7 +191,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time", + "pattern": "time_sec", "type": "date" }, { @@ -249,7 +213,7 @@ { "alias": "", "format": "table", - "rawSql": "SELECT cast(null as datetime) as time", + "rawSql": "SELECT cast(null as datetime) as time_sec", "refId": "A", "target": "" } @@ -260,7 +224,7 @@ }, { "columns": [], - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -281,7 +245,7 @@ { "alias": "Time", "dateFormat": "YYYY-MM-DD HH:mm:ss", - "pattern": "time", + "pattern": "time_sec", "type": "date" }, { @@ -303,18 +267,18 @@ { "alias": "", "format": "table", - "rawSql": "SELECT GETDATE() as time", + "rawSql": "SELECT cast(NOW() as datetime) as time_sec", "refId": "A", "target": "" } ], - "title": "GETDATE() as time", + "title": "cast()NOW() as datetime) as time", "transform": "table", "type": "table" }, { "columns": [], - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -357,12 +321,12 @@ { "alias": "", "format": "table", - "rawSql": "SELECT GETUTCDATE() as time", + "rawSql": "SELECT NOW() as time", "refId": "A", "target": "" } ], - "title": "GETUTCDATE() as time", + "title": "NOW() as time", "transform": "table", "type": "table" }, @@ -371,11 +335,11 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, + "h": 6, + "w": 6, "x": 0, "y": 7 }, @@ -405,7 +369,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -454,12 +418,12 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 8, + "h": 6, + "w": 6, + "x": 6, "y": 7 }, "id": 9, @@ -488,7 +452,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m', NULL) AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', NULL), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -537,12 +501,12 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 16, + "h": 6, + "w": 6, + "x": 12, "y": 7 }, "id": 10, @@ -571,7 +535,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m', 10.0) AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '5m') ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', 10.0), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -615,18 +579,101 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mysql-ds-tests", + "fill": 2, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 7 + }, + "id": 36, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', previous), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "timeGroup macro 5m with fill(previous)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, + "h": 6, + "w": 6, "x": 0, - "y": 16 + "y": 13 }, "id": 16, "legend": { @@ -654,7 +701,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize') AS time, avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -703,13 +750,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 16 + "h": 6, + "w": 6, + "x": 6, + "y": 13 }, "id": 12, "legend": { @@ -737,7 +784,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize', NULL) AS time, sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', NULL), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -786,13 +833,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 16, - "y": 16 + "h": 6, + "w": 6, + "x": 12, + "y": 13 }, "id": 13, "legend": { @@ -820,7 +867,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize', 100.0) AS time, sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY $__timeGroup(time, '$summarize') ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', 100.0), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -864,18 +911,101 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mysql-ds-tests", + "fill": 2, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 13 + }, + "id": 37, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', previous), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Metrics - timeGroup macro $summarize with fill(previous)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 25 + "y": 19 }, "id": 27, "legend": { @@ -907,14 +1037,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize') as time, \n measurement + ' - value one' as metric, \n avg(valueOne) as valueOne\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__timeGroup(time, '$summarize'), \n measurement \nORDER BY 1", + "rawSql": "SELECT \n $__timeGroupAlias(time, '$summarize'), \n measurement as metric, \n avg(valueOne) as valueOne,\n avg(valueTwo) as valueTwo\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement IN($metric)\nGROUP BY 1, 2\nORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize') as time, \n measurement + ' - value two' as metric, \n avg(valueTwo) as valueTwo \nFROM\n metric_values\nWHERE\n $__timeFilter(time) AND\n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__timeGroup(time, '$summarize'), \n measurement \nORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -962,13 +1086,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 25 + "y": 19 }, "id": 5, "legend": { @@ -1010,14 +1134,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize') as time, \n avg(valueOne) as valueOne, \n avg(valueTwo) as valueTwo \nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n ($metric = 'ALL' OR measurement = $metric)\nGROUP BY \n $__timeGroup(time, '$summarize')\nORDER BY 1", + "rawSql": "SELECT \n $__timeGroupAlias(time, '$summarize'), \n avg(valueOne) as valueOne, \n avg(valueTwo) as valueTwo \nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n measurement IN($metric)\nGROUP BY 1\nORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT \n time,\n avg(valueOne) OVER (ORDER BY time ROWS BETWEEN 6 PRECEDING AND 6 FOLLOWING) as MovingAverageValueOne,\n avg(valueTwo) OVER (ORDER BY time ROWS BETWEEN 6 PRECEDING AND 6 FOLLOWING) as MovingAverageValueTwo\nFROM\n metric_values \nWHERE \n $__timeFilter(time) AND \n ($metric = 'ALL' OR measurement = $metric)\nORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1065,13 +1183,197 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 33 + "y": 27 + }, + "id": 38, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n $__unixEpochGroupAlias(timeInt32, '$summarize'), \n measurement, \n avg(valueOne) as valueOne,\n avg(valueTwo) as valueTwo\nFROM\n metric_values \nWHERE\n $__unixEpochFilter(timeInt32) AND\n measurement in($metric)\nGROUP BY 1, 2\nORDER BY 1, 2", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column using unixEpochGroup macro ($summarize)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mysql-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 39, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "MovingAverageValueOne", + "dashes": true, + "lines": false + }, + { + "alias": "MovingAverageValueTwo", + "dashes": true, + "lines": false, + "yaxis": 1 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n $__unixEpochGroupAlias(timeInt32, '$summarize'), \n avg(valueOne) as valueOne,\n avg(valueTwo) as valueTwo\nFROM\n metric_values \nWHERE\n $__unixEpochFilter(timeInt32) AND\n measurement in($metric)\nGROUP BY 1\nORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column using unixEpochGroup macro ($summarize)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-mysql-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 35 }, "id": 4, "legend": { @@ -1103,14 +1405,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1158,13 +1454,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 33 + "y": 35 }, "id": 28, "legend": { @@ -1194,7 +1490,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values ORDER BY 1", + "rawSql": "SELECT $__time(time), valueOne, valueTwo FROM metric_values ORDER BY 1", "refId": "A" } ], @@ -1243,13 +1539,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 41 + "y": 43 }, "id": 19, "legend": { @@ -1281,14 +1577,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1336,13 +1626,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 41 + "y": 43 }, "id": 18, "legend": { @@ -1372,7 +1662,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" } ], @@ -1421,13 +1711,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 49 + "y": 51 }, "id": 17, "legend": { @@ -1459,14 +1749,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values WHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1514,13 +1798,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 49 + "y": 51 }, "id": 20, "legend": { @@ -1550,7 +1834,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" } ], @@ -1594,192 +1878,18 @@ "alignLevel": null } }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "${DS_MSSQL_TEST}", - "fill": 2, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 29, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "hideEmpty": false, - "hideZero": false, - "max": true, - "min": true, - "rightSide": true, - "show": true, - "total": true, - "values": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "null", - "percentage": false, - "pointradius": 3, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "alias": "", - "format": "time_series", - "rawSql": "DECLARE\n @from int = $__unixEpochFrom(), \n @to int = $__unixEpochTo(), \n @interval nvarchar(50) = '$summarize', \n @metric nvarchar(200) = $metric\n \nEXEC dbo.sp_test_epoch @from, @to, @interval, @metric", - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Stored procedure support using epoch", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": "0", - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "${DS_MSSQL_TEST}", - "fill": 2, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 30, - "legend": { - "alignAsTable": true, - "avg": true, - "current": true, - "hideEmpty": false, - "hideZero": false, - "max": true, - "min": true, - "rightSide": true, - "show": true, - "total": true, - "values": true - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "null", - "percentage": false, - "pointradius": 3, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, - "targets": [ - { - "alias": "", - "format": "time_series", - "rawSql": "DECLARE\n @from datetime = $__timeFrom(), \n @to datetime = $__timeTo(), \n @interval nvarchar(50) = '$summarize', \n @metric nvarchar(200) = $metric\n \nEXEC dbo.sp_test_datetime @from, @to, @interval, @metric", - "refId": "A" - } - ], - "thresholds": [], - "timeFrom": null, - "timeShift": null, - "title": "Stored procedure support using datetime", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": "0", - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, { "aliasColors": {}, "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 65 + "y": 59 }, "id": 14, "legend": { @@ -1809,14 +1919,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1867,13 +1971,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 65 + "y": 59 }, "id": 15, "legend": { @@ -1903,7 +2007,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" } ], @@ -1954,13 +2058,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 73 + "y": 67 }, "id": 25, "legend": { @@ -1990,14 +2094,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2048,13 +2146,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 73 + "y": 67 }, "id": 22, "legend": { @@ -2084,7 +2182,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" } ], @@ -2135,13 +2233,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 81 + "y": 75 }, "id": 21, "legend": { @@ -2171,14 +2269,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2229,13 +2321,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 81 + "y": 75 }, "id": 26, "legend": { @@ -2265,7 +2357,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" } ], @@ -2316,13 +2408,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 89 + "y": 83 }, "id": 23, "legend": { @@ -2352,14 +2444,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value one' as metric, valueOne FROM metric_values\nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values WHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement + ' - value two' as metric, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2410,13 +2496,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_MSSQL_TEST}", + "datasource": "gdev-mysql-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 89 + "y": 83 }, "id": 24, "legend": { @@ -2446,7 +2532,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values \nWHERE $__timeFilter(time) AND ($metric = 'ALL' OR measurement = $metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), valueOne, valueTwo FROM metric_values\nWHERE $__timeFilter(time) AND measurement IN($metric) ORDER BY 1", "refId": "A" } ], @@ -2496,22 +2582,45 @@ "refresh": false, "schemaVersion": 16, "style": "dark", - "tags": [], + "tags": [ + "gdev", + "mysql" + ], "templating": { "list": [ { - "allValue": "'ALL'", - "current": {}, - "datasource": "${DS_MSSQL_TEST}", + "allValue": "", + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "gdev-mysql-ds-tests", "hide": 0, "includeAll": true, "label": "Metric", - "multi": false, + "multi": true, "name": "metric", - "options": [], + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "Metric A", + "value": "Metric A" + }, + { + "selected": false, + "text": "Metric B", + "value": "Metric B" + } + ], "query": "SELECT DISTINCT measurement FROM metric_values", - "refresh": 1, + "refresh": 0, "regex": "", + "skipUrlSync": false, "sort": 0, "tagValuesQuery": "", "tags": [], @@ -2564,6 +2673,7 @@ ], "query": "1s,10s,30s,1m,5m,10m", "refresh": 2, + "skipUrlSync": false, "type": "interval" } ] @@ -2598,7 +2708,7 @@ ] }, "timezone": "", - "title": "Microsoft SQL Server Data Source Test", - "uid": "GlAqcPgmz", - "version": 58 + "title": "Datasource tests - MySQL (unittest)", + "uid": "Hmf8FDkmz", + "version": 2 } \ No newline at end of file diff --git a/docker/blocks/postgres/dashboard.json b/devenv/dev-dashboards/datasource_tests_postgres_fakedata.json similarity index 91% rename from docker/blocks/postgres/dashboard.json rename to devenv/dev-dashboards/datasource_tests_postgres_fakedata.json index 77b0ceac624..508cae86bc3 100644 --- a/docker/blocks/postgres/dashboard.json +++ b/devenv/dev-dashboards/datasource_tests_postgres_fakedata.json @@ -1,40 +1,4 @@ { - "__inputs": [ - { - "name": "DS_POSTGRESQL", - "label": "PostgreSQL", - "description": "", - "type": "datasource", - "pluginId": "postgres", - "pluginName": "PostgreSQL" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "" - }, - { - "type": "datasource", - "id": "postgres", - "name": "PostgreSQL", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "" - } - ], "annotations": { "list": [ { @@ -52,8 +16,7 @@ "editable": true, "gnetId": null, "graphTooltip": 0, - "id": null, - "iteration": 1518601837383, + "iteration": 1532620601931, "links": [], "panels": [ { @@ -63,7 +26,7 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRESQL}", + "datasource": "gdev-postgres", "fill": 2, "gridPos": { "h": 9, @@ -150,14 +113,18 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRESQL}", + "datasource": "gdev-postgres", "fill": 2, "gridPos": { "h": 18, @@ -236,14 +203,18 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRESQL}", + "datasource": "gdev-postgres", "fill": 2, "gridPos": { "h": 9, @@ -316,11 +287,15 @@ "min": null, "show": true } - ] + ], + "yaxis": { + "align": false, + "alignLevel": null + } }, { "columns": [], - "datasource": "${DS_POSTGRESQL}", + "datasource": "gdev-postgres", "fontSize": "100%", "gridPos": { "h": 9, @@ -377,6 +352,7 @@ "schemaVersion": 16, "style": "dark", "tags": [ + "gdev", "fake-data-gen", "postgres" ], @@ -384,8 +360,11 @@ "list": [ { "allValue": null, - "current": {}, - "datasource": "${DS_POSTGRESQL}", + "current": { + "text": "America", + "value": "America" + }, + "datasource": "gdev-postgres", "hide": 0, "includeAll": false, "label": "Datacenter", @@ -395,6 +374,7 @@ "query": "SELECT DISTINCT datacenter FROM grafana_metric", "refresh": 1, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -404,8 +384,11 @@ }, { "allValue": null, - "current": {}, - "datasource": "${DS_POSTGRESQL}", + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "gdev-postgres", "hide": 0, "includeAll": true, "label": "Hostname", @@ -415,6 +398,7 @@ "query": "SELECT DISTINCT hostname FROM grafana_metric WHERE datacenter='$datacenter'", "refresh": 1, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -507,6 +491,7 @@ ], "query": "1s,10s,30s,1m,5m,10m,30m,1h,6h,12h,1d,7d,14d,30d", "refresh": 2, + "skipUrlSync": false, "type": "interval" } ] @@ -541,7 +526,7 @@ ] }, "timezone": "", - "title": "Grafana Fake Data Gen - PostgreSQL", + "title": "Datasource tests - Postgres", "uid": "JYola5qzz", - "version": 1 + "version": 4 } \ No newline at end of file diff --git a/docker/blocks/postgres_tests/dashboard.json b/devenv/dev-dashboards/datasource_tests_postgres_unittest.json similarity index 78% rename from docker/blocks/postgres_tests/dashboard.json rename to devenv/dev-dashboards/datasource_tests_postgres_unittest.json index 9efbe90bdfe..3c56868e9ff 100644 --- a/docker/blocks/postgres_tests/dashboard.json +++ b/devenv/dev-dashboards/datasource_tests_postgres_unittest.json @@ -1,40 +1,4 @@ { - "__inputs": [ - { - "name": "DS_POSTGRES_TEST", - "label": "Postgres TEST", - "description": "", - "type": "datasource", - "pluginId": "postgres", - "pluginName": "PostgreSQL" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "5.0.0" - }, - { - "type": "datasource", - "id": "postgres", - "name": "PostgreSQL", - "version": "5.0.0" - }, - { - "type": "panel", - "id": "table", - "name": "Table", - "version": "5.0.0" - } - ], "annotations": { "list": [ { @@ -47,7 +11,7 @@ "type": "dashboard" }, { - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "enable": false, "hide": false, "iconColor": "#6ed0e0", @@ -59,7 +23,7 @@ "type": "tags" }, { - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "enable": false, "hide": false, "iconColor": "rgba(255, 96, 96, 1)", @@ -71,7 +35,7 @@ "type": "tags" }, { - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "enable": false, "hide": false, "iconColor": "#7eb26d", @@ -83,7 +47,7 @@ "type": "tags" }, { - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "enable": false, "hide": false, "iconColor": "#1f78c1", @@ -96,16 +60,16 @@ } ] }, + "description": "Run the postgres unit tests to generate the data backing this dashboard", "editable": true, "gnetId": null, "graphTooltip": 0, - "id": null, - "iteration": 1523320929325, + "iteration": 1534507993194, "links": [], "panels": [ { "columns": [], - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fontSize": "100%", "gridPos": { "h": 4, @@ -152,7 +116,7 @@ }, { "columns": [], - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -206,7 +170,7 @@ }, { "columns": [], - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -260,7 +224,7 @@ }, { "columns": [], - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -314,7 +278,7 @@ }, { "columns": [], - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fontSize": "100%", "gridPos": { "h": 3, @@ -371,11 +335,11 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, + "h": 6, + "w": 6, "x": 0, "y": 7 }, @@ -405,7 +369,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -454,12 +418,12 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 8, + "h": 6, + "w": 6, + "x": 6, "y": 7 }, "id": 9, @@ -488,7 +452,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m', NULL), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', NULL), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -537,12 +501,12 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 16, + "h": 6, + "w": 6, + "x": 12, "y": 7 }, "id": 10, @@ -571,7 +535,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '5m', 10.0), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', 10.0), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -615,18 +579,101 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-postgres-ds-tests", + "fill": 2, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 7 + }, + "id": 36, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeGroupAlias(time, '5m', previous), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "timeGroup macro 5m with fill(previous)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, + "h": 6, + "w": 6, "x": 0, - "y": 16 + "y": 13 }, "id": 16, "legend": { @@ -654,7 +701,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize'), avg(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -703,13 +750,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 16 + "h": 6, + "w": 6, + "x": 6, + "y": 13 }, "id": 12, "legend": { @@ -737,7 +784,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize', NULL), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', NULL), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -786,13 +833,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { - "h": 9, - "w": 8, - "x": 16, - "y": 16 + "h": 6, + "w": 6, + "x": 12, + "y": 13 }, "id": 13, "legend": { @@ -820,7 +867,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeGroup(time, '$summarize', 100.0), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', 100.0), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", "refId": "A" } ], @@ -864,18 +911,101 @@ "alignLevel": null } }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-postgres-ds-tests", + "fill": 2, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 13 + }, + "id": 37, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT $__timeGroupAlias(time, '$summarize', previous), sum(value) as value FROM metric WHERE $__timeFilter(time) GROUP BY 1 ORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Metrics - timeGroup macro $summarize with fill(previous)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, { "aliasColors": {}, "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 25 + "y": 19 }, "id": 27, "legend": { @@ -907,14 +1037,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize'), \n measurement || ' - value one' as metric, \n avg(\"valueOne\") as \"valueOne\"\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement in($metric)\nGROUP BY 1, 2\nORDER BY 1", + "rawSql": "SELECT \n $__timeGroupAlias(time, '$summarize'), \n measurement, \n avg(\"valueOne\") as \"valueOne\",\n avg(\"valueTwo\") as \"valueTwo\"\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement in($metric)\nGROUP BY 1, 2\nORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize'), \n measurement || ' - value two' as metric, \n avg(\"valueTwo\") as \"valueTwo\"\nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement in($metric)\nGROUP BY 1, 2\nORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -962,13 +1086,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 25 + "y": 19 }, "id": 5, "legend": { @@ -998,7 +1122,7 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT \n $__timeGroup(time, '$summarize'), \n avg(\"valueOne\") as \"valueOne\", \n avg(\"valueTwo\") as \"valueTwo\" \nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement in($metric)\nGROUP BY 1\nORDER BY 1", + "rawSql": "SELECT \n $__timeGroupAlias(time, '$summarize'), \n avg(\"valueOne\") as \"valueOne\", \n avg(\"valueTwo\") as \"valueTwo\" \nFROM\n metric_values \nWHERE\n $__timeFilter(time) AND\n measurement in($metric)\nGROUP BY 1\nORDER BY 1", "refId": "A" } ], @@ -1047,13 +1171,185 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 33 + "y": 27 + }, + "id": 38, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n $__unixEpochGroupAlias(\"timeInt32\", '$summarize'), \n measurement, \n avg(\"valueOne\") as \"valueOne\",\n avg(\"valueTwo\") as \"valueTwo\"\nFROM\n metric_values \nWHERE\n $__unixEpochFilter(\"timeInt32\") AND\n measurement in($metric)\nGROUP BY 1, 2\nORDER BY 1, 2", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series with metric column using unixEpochGroup macro ($summarize)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-postgres-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 39, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 3, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "format": "time_series", + "rawSql": "SELECT \n $__unixEpochGroupAlias(\"timeInt32\", '$summarize'), \n avg(\"valueOne\") as \"valueOne\",\n avg(\"valueTwo\") as \"valueTwo\"\nFROM\n metric_values \nWHERE\n $__unixEpochFilter(\"timeInt32\") AND\n measurement in($metric)\nGROUP BY 1\nORDER BY 1", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Multiple series without metric column using timeGroup macro ($summarize)", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-postgres-ds-tests", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 35 }, "id": 4, "legend": { @@ -1085,14 +1381,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1140,13 +1430,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 33 + "y": 35 }, "id": 28, "legend": { @@ -1225,13 +1515,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 41 + "y": 43 }, "id": 19, "legend": { @@ -1263,14 +1553,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1318,13 +1602,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 41 + "y": 43 }, "id": 18, "legend": { @@ -1403,13 +1687,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 49 + "y": 51 }, "id": 17, "legend": { @@ -1441,14 +1725,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1496,13 +1774,13 @@ "bars": false, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 2, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 49 + "y": 51 }, "id": 20, "legend": { @@ -1581,13 +1859,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 57 + "y": 59 }, "id": 14, "legend": { @@ -1617,14 +1895,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1675,13 +1947,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 57 + "y": 59 }, "id": 15, "legend": { @@ -1762,13 +2034,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 65 + "y": 67 }, "id": 25, "legend": { @@ -1798,14 +2070,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -1856,13 +2122,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 65 + "y": 67 }, "id": 22, "legend": { @@ -1943,13 +2209,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 73 + "y": 75 }, "id": 21, "legend": { @@ -1979,14 +2245,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2037,13 +2297,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 73 + "y": 75 }, "id": 26, "legend": { @@ -2124,13 +2384,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 0, - "y": 81 + "y": 83 }, "id": 23, "legend": { @@ -2160,14 +2420,8 @@ { "alias": "", "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value one' as metric, \"valueOne\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", + "rawSql": "SELECT $__timeEpoch(time), measurement, \"valueOne\", \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", "refId": "A" - }, - { - "alias": "", - "format": "time_series", - "rawSql": "SELECT $__timeEpoch(time), measurement || ' - value two' as metric, \"valueTwo\" FROM metric_values \nWHERE $__timeFilter(time) AND measurement in($metric) ORDER BY 1", - "refId": "B" } ], "thresholds": [], @@ -2218,13 +2472,13 @@ "bars": true, "dashLength": 10, "dashes": false, - "datasource": "${DS_POSTGRES_TEST}", + "datasource": "gdev-postgres-ds-tests", "fill": 1, "gridPos": { "h": 8, "w": 12, "x": 12, - "y": 81 + "y": 83 }, "id": 24, "legend": { @@ -2304,22 +2558,49 @@ "refresh": false, "schemaVersion": 16, "style": "dark", - "tags": [], + "tags": [ + "gdev", + "postgres" + ], "templating": { "list": [ { "allValue": null, - "current": {}, - "datasource": "${DS_POSTGRES_TEST}", + "current": { + "selected": true, + "tags": [], + "text": "All", + "value": [ + "$__all" + ] + }, + "datasource": "gdev-postgres-ds-tests", "hide": 0, "includeAll": true, "label": "Metric", "multi": true, "name": "metric", - "options": [], + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "Metric A", + "value": "Metric A" + }, + { + "selected": false, + "text": "Metric B", + "value": "Metric B" + } + ], "query": "SELECT DISTINCT measurement FROM metric_values", - "refresh": 1, + "refresh": 0, "regex": "", + "skipUrlSync": false, "sort": 1, "tagValuesQuery": "", "tags": [], @@ -2372,6 +2653,7 @@ ], "query": "1s,10s,30s,1m,5m,10m", "refresh": 2, + "skipUrlSync": false, "type": "interval" } ] @@ -2406,7 +2688,7 @@ ] }, "timezone": "", - "title": "Postgres Data Source Test", + "title": "Datasource tests - Postgres (unittest)", "uid": "vHQdlVziz", - "version": 14 + "version": 1 } \ No newline at end of file diff --git a/devenv/dev-dashboards/panel_tests_graph.json b/devenv/dev-dashboards/panel_tests_graph.json new file mode 100644 index 00000000000..8a1770f0fa6 --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_graph.json @@ -0,0 +1,1558 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 1, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "no_data_points", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "No Data Points Warning", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "datapoints_outside_range", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Datapoints Outside Range Warning", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "random_walk", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Random walk series", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 7 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "random_walk", + "target": "" + } + ], + "thresholds": [], + "timeFrom": "2s", + "timeShift": null, + "title": "Millisecond res x-axis and tooltip", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Just verify that the tooltip time has millisecond resolution ", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 6, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 9, + "w": 16, + "x": 0, + "y": 14 + }, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "B-series", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "2000,3000,4000,1000,3000,10000", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "2 yaxis and axis labels", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": "Perecent", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": "Pressure", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Verify that axis labels look ok", + "editable": true, + "error": false, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 14 + }, + "id": 7, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 23 + }, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "null value connected", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 23 + }, + "id": 10, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "null value null as zero", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Should be a long line connecting the null region in the `connected` mode, and in zero it should just be a line with zero value at the null points. ", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 23 + }, + "id": 13, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 30 + }, + "id": 9, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "B-series", + "zindex": -3 + } + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Stacking value ontop of nulls", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Stacking values on top of nulls, should treat the null values as zero. ", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 30 + }, + "id": 14, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 37 + }, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "B-series", + "zindex": -3 + } + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "", + "hide": false, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Stacking all series null segment", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Stacking when all values are null should leave a gap in the graph", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 37 + }, + "id": 15, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "decimals": 3, + "fill": 1, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 44 + }, + "id": 20, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table Single Series Should Take Minimum Height", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "decimals": 3, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 51 + }, + "id": 16, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table No Scroll Visible", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "decimals": 3, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 51 + }, + "id": 17, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "E", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "F", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "G", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "H", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "I", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "J", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table Should Scroll", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "decimals": 3, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 58 + }, + "id": 18, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table No Scroll Visible", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "decimals": 3, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 58 + }, + "id": 19, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "E", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "F", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "G", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "H", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "I", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "J", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "K", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "L", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table No Scroll Visible", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": false, + "revision": 8, + "schemaVersion": 16, + "style": "dark", + "tags": [ + "gdev", + "panel-tests" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Panel Tests - Graph", + "uid": "5SdHCadmz", + "version": 3 +} diff --git a/devenv/dev-dashboards/panel_tests_singlestat.json b/devenv/dev-dashboards/panel_tests_singlestat.json new file mode 100644 index 00000000000..2d69f27bcb6 --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_singlestat.json @@ -0,0 +1,574 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "gdev-testdata", + "decimals": null, + "description": "", + "format": "ms", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 2, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "postfix", + "postfixFontSize": "50%", + "prefix": "prefix", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,2,3,4,5" + } + ], + "thresholds": "5,10", + "title": "prefix 3 ms (green) postfixt + sparkline", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorPrefix": false, + "colorValue": true, + "colors": [ + "#d44a3a", + "rgba(237, 129, 40, 0.89)", + "#299c46" + ], + "datasource": "gdev-testdata", + "decimals": null, + "description": "", + "format": "ms", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 3, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,2,3,4,5" + } + ], + "thresholds": "5,10", + "title": "3 ms (red) + full height sparkline", + "type": "singlestat", + "valueFontSize": "200%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": true, + "colorPrefix": false, + "colorValue": false, + "colors": [ + "#d44a3a", + "rgba(237, 129, 40, 0.89)", + "#299c46" + ], + "datasource": "gdev-testdata", + "decimals": null, + "description": "", + "format": "ms", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 4, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,2,3,4,5" + } + ], + "thresholds": "5,10", + "title": "3 ms + red background", + "type": "singlestat", + "valueFontSize": "200%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorPrefix": false, + "colorValue": true, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "gdev-testdata", + "decimals": null, + "description": "", + "format": "ms", + "gauge": { + "maxValue": 150, + "minValue": 0, + "show": true, + "thresholdLabels": true, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 7 + }, + "id": 5, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,20,80" + } + ], + "thresholds": "81,90", + "title": "80 ms green gauge, thresholds 81, 90", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorPrefix": false, + "colorValue": true, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "gdev-testdata", + "decimals": null, + "description": "", + "format": "ms", + "gauge": { + "maxValue": 150, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 7 + }, + "id": 6, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,20,80" + } + ], + "thresholds": "81,90", + "title": "80 ms green gauge, thresholds 81, 90, no labels", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorPrefix": false, + "colorValue": true, + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "datasource": "gdev-testdata", + "decimals": null, + "description": "", + "format": "ms", + "gauge": { + "maxValue": 150, + "minValue": 0, + "show": true, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 7 + }, + "id": 7, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,20,80" + } + ], + "thresholds": "81,90", + "title": "80 ms green gauge, thresholds 81, 90, no markers or labels", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + } + ], + "refresh": false, + "revision": 8, + "schemaVersion": 16, + "style": "dark", + "tags": [ + "gdev", + "panel-tests" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Panel Tests - Singlestat", + "uid": "singlestat", + "version": 14 +} diff --git a/devenv/dev-dashboards/panel_tests_table.json b/devenv/dev-dashboards/panel_tests_table.json new file mode 100644 index 00000000000..8337e9cd746 --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_table.json @@ -0,0 +1,453 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "columns": [], + "datasource": "gdev-testdata", + "fontSize": "100%", + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 3, + "links": [], + "pageSize": 10, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": "cell", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "ColorCell", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "currencyUSD" + }, + { + "alias": "", + "colorMode": "value", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "ColorValue", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "Bps" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "server1", + "expr": "", + "format": "table", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0,20,10" + }, + { + "alias": "server2", + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "title": "Time series to rows (2 pages)", + "transform": "timeseries_to_rows", + "type": "table" + }, + { + "columns": [ + { + "text": "Avg", + "value": "avg" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Current", + "value": "current" + } + ], + "datasource": "gdev-testdata", + "fontSize": "100%", + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "links": [], + "pageSize": 10, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": "cell", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "ColorCell", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "currencyUSD" + }, + { + "alias": "", + "colorMode": "value", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "ColorValue", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "Bps" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "server1", + "expr": "", + "format": "table", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0,20,10" + }, + { + "alias": "server2", + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "title": "Time series aggregations", + "transform": "timeseries_aggregations", + "type": "table" + }, + { + "columns": [], + "datasource": "gdev-testdata", + "fontSize": "100%", + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 5, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": "row", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "/Color/", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "currencyUSD" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "ColorValue", + "expr": "", + "format": "table", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0,20,10" + } + ], + "title": "color row by threshold", + "transform": "timeseries_to_columns", + "type": "table" + }, + { + "columns": [], + "datasource": "gdev-testdata", + "fontSize": "100%", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 2, + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": "cell", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "ColorCell", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "currencyUSD" + }, + { + "alias": "", + "colorMode": "value", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "mappingType": 1, + "pattern": "ColorValue", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "Bps" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "ColorValue", + "expr": "", + "format": "table", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0,20,10" + }, + { + "alias": "ColorCell", + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "5,1,2,3,4,5,10,20" + } + ], + "title": "Column style thresholds & units", + "transform": "timeseries_to_columns", + "type": "table" + } + ], + "refresh": false, + "revision": 8, + "schemaVersion": 16, + "style": "dark", + "tags": [ + "gdev", + "panel-tests" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Panel Tests - Table", + "uid": "pttable", + "version": 1 +} diff --git a/public/app/plugins/app/testdata/dashboards/alerts.json b/devenv/dev-dashboards/testdata_alerts.json similarity index 98% rename from public/app/plugins/app/testdata/dashboards/alerts.json rename to devenv/dev-dashboards/testdata_alerts.json index 159df0f458b..8c2edebf155 100644 --- a/public/app/plugins/app/testdata/dashboards/alerts.json +++ b/devenv/dev-dashboards/testdata_alerts.json @@ -1,6 +1,6 @@ { "revision": 2, - "title": "TestData - Alerts", + "title": "Alerting with TestData", "tags": [ "grafana-test" ], @@ -48,7 +48,7 @@ }, "aliasColors": {}, "bars": false, - "datasource": "Grafana TestData", + "datasource": "gdev-testdata", "editable": true, "error": false, "fill": 1, @@ -161,7 +161,7 @@ }, "aliasColors": {}, "bars": false, - "datasource": "Grafana TestData", + "datasource": "gdev-testdata", "editable": true, "error": false, "fill": 1, diff --git a/devenv/setup.sh b/devenv/setup.sh index 0900b57e583..6412bbc98ea 100755 --- a/devenv/setup.sh +++ b/devenv/setup.sh @@ -1,4 +1,4 @@ -#/bin/bash +#!/bin/bash bulkDashboard() { @@ -22,42 +22,43 @@ requiresJsonnet() { fi } -defaultDashboards() { - requiresJsonnet - - ln -s -f -r ./dashboards/dev-dashboards/dev-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml +devDashboards() { + echo -e "\xE2\x9C\x94 Setting up all dev dashboards using provisioning" + ln -s -f ../../../devenv/dashboards.yaml ../conf/provisioning/dashboards/dev.yaml } -defaultDatasources() { - echo "setting up all default datasources using provisioning" +devDatasources() { + echo -e "\xE2\x9C\x94 Setting up all dev datasources using provisioning" - ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml + ln -s -f ../../../devenv/datasources.yaml ../conf/provisioning/datasources/dev.yaml } usage() { - echo -e "install.sh\n\tThis script installs my basic setup for a debian laptop\n" + echo -e "\n" echo "Usage:" echo " bulk-dashboards - create and provisioning 400 dashboards" - echo " default-datasources - provisiong all core datasources" + echo " no args - provisiong core datasources and dev dashboards" } main() { - local cmd=$1 + echo -e "------------------------------------------------------------------" + echo -e "This script setups provisioning for dev datasources and dashboards" + echo -e "------------------------------------------------------------------" + echo -e "\n" - if [[ -z "$cmd" ]]; then - usage - exit 1 - fi + local cmd=$1 if [[ $cmd == "bulk-dashboards" ]]; then bulkDashboard - elif [[ $cmd == "default-datasources" ]]; then - defaultDatasources - elif [[ $cmd == "default-dashboards" ]]; then - defaultDashboards else + devDashboards + devDatasources + fi + + if [[ -z "$cmd" ]]; then usage fi + } main "$@" diff --git a/docker/blocks/nginx_proxy/Dockerfile b/docker/blocks/nginx_proxy/Dockerfile index 9ded20dfdda..04de507499d 100644 --- a/docker/blocks/nginx_proxy/Dockerfile +++ b/docker/blocks/nginx_proxy/Dockerfile @@ -1,3 +1,4 @@ FROM nginx:alpine -COPY nginx.conf /etc/nginx/nginx.conf \ No newline at end of file +COPY nginx.conf /etc/nginx/nginx.conf +COPY htpasswd /etc/nginx/htpasswd diff --git a/docker/blocks/nginx_proxy/htpasswd b/docker/blocks/nginx_proxy/htpasswd new file mode 100755 index 00000000000..e2c5eeeff7b --- /dev/null +++ b/docker/blocks/nginx_proxy/htpasswd @@ -0,0 +1,3 @@ +user1:$apr1$1odeeQb.$kwV8D/VAAGUDU7pnHuKoV0 +user2:$apr1$A2kf25r.$6S0kp3C7vIuixS5CL0XA9. +admin:$apr1$IWn4DoRR$E2ol7fS/dkI18eU4bXnBO1 diff --git a/docker/blocks/nginx_proxy/nginx.conf b/docker/blocks/nginx_proxy/nginx.conf index 18e27b3fb01..860d3d0b89f 100644 --- a/docker/blocks/nginx_proxy/nginx.conf +++ b/docker/blocks/nginx_proxy/nginx.conf @@ -13,7 +13,26 @@ http { listen 10080; location /grafana/ { + ################################################################ + # Enable these settings to test with basic auth and an auth proxy header + # the htpasswd file contains an admin user with password admin and + # user1: grafana and user2: grafana + ################################################################ + + # auth_basic "Restricted Content"; + # auth_basic_user_file /etc/nginx/htpasswd; + + ################################################################ + # To use the auth proxy header, set the following in custom.ini: + # [auth.proxy] + # enabled = true + # header_name = X-WEBAUTH-USER + # header_property = username + ################################################################ + + # proxy_set_header X-WEBAUTH-USER $remote_user; + proxy_pass http://localhost:3000/; } } -} \ No newline at end of file +} diff --git a/docker/blocks/openldap/Dockerfile b/docker/blocks/openldap/Dockerfile index c9b928ad56a..76172e133a4 100644 --- a/docker/blocks/openldap/Dockerfile +++ b/docker/blocks/openldap/Dockerfile @@ -8,7 +8,8 @@ ENV OPENLDAP_VERSION 2.4.40 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ - slapd=${OPENLDAP_VERSION}* && \ + slapd=${OPENLDAP_VERSION}* \ + ldap-utils && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* @@ -22,6 +23,7 @@ COPY modules/ /etc/ldap.dist/modules COPY prepopulate/ /etc/ldap.dist/prepopulate COPY entrypoint.sh /entrypoint.sh +COPY prepopulate.sh /prepopulate.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/blocks/openldap/entrypoint.sh b/docker/blocks/openldap/entrypoint.sh index d560b78d388..d202ed14b31 100755 --- a/docker/blocks/openldap/entrypoint.sh +++ b/docker/blocks/openldap/entrypoint.sh @@ -76,13 +76,14 @@ EOF IFS=","; declare -a modules=($SLAPD_ADDITIONAL_MODULES); unset IFS for module in "${modules[@]}"; do - slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/modules/${module}.ldif" >/dev/null 2>&1 + echo "Adding module ${module}" + slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/modules/${module}.ldif" >/dev/null 2>&1 done fi - for file in `ls /etc/ldap/prepopulate/*.ldif`; do - slapadd -F /etc/ldap/slapd.d -l "$file" - done + # This needs to run in background + # Will prepopulate entries after ldap daemon has started + ./prepopulate.sh & chown -R openldap:openldap /etc/ldap/slapd.d/ /var/lib/ldap/ /var/run/slapd/ else diff --git a/docker/blocks/openldap/ldap_dev.toml b/docker/blocks/openldap/ldap_dev.toml new file mode 100644 index 00000000000..e79771b57de --- /dev/null +++ b/docker/blocks/openldap/ldap_dev.toml @@ -0,0 +1,85 @@ +# To troubleshoot and get more log info enable ldap debug logging in grafana.ini +# [log] +# filters = ldap:debug + +[[servers]] +# Ldap server host (specify multiple hosts space separated) +host = "127.0.0.1" +# Default port is 389 or 636 if use_ssl = true +port = 389 +# Set to true if ldap server supports TLS +use_ssl = false +# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS) +start_tls = false +# set to true if you want to skip ssl cert validation +ssl_skip_verify = false +# set to the path to your root CA certificate or leave unset to use system defaults +# root_ca_cert = "/path/to/certificate.crt" + +# Search user bind dn +bind_dn = "cn=admin,dc=grafana,dc=org" +# Search user bind password +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" +bind_password = 'grafana' + +# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" +search_filter = "(cn=%s)" + +# An array of base dns to search through +search_base_dns = ["dc=grafana,dc=org"] + +# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups. +# This is done by enabling group_search_filter below. You must also set member_of= "cn" +# in [servers.attributes] below. + +# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN +# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of +# below in such a way that the user's recursive group membership is considered. +# +# Nested Groups + Active Directory (AD) Example: +# +# AD groups store the Distinguished Names (DNs) of members, so your filter must +# recursively search your groups for the authenticating user's DN. For example: +# +# group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)" +# group_search_filter_user_attribute = "distinguishedName" +# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] +# +# [servers.attributes] +# ... +# member_of = "distinguishedName" + +## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available) +# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))" +## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter. +## Defaults to the value of username in [server.attributes] +## Valid options are any of your values in [servers.attributes] +## If you are using nested groups you probably want to set this and member_of in +## [servers.attributes] to "distinguishedName" +# group_search_filter_user_attribute = "distinguishedName" +## An array of the base DNs to search through for groups. Typically uses ou=groups +# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] + +# Specify names of the ldap attributes your ldap uses +[servers.attributes] +name = "givenName" +surname = "sn" +username = "cn" +member_of = "memberOf" +email = "email" + +# Map ldap groups to grafana org roles +[[servers.group_mappings]] +group_dn = "cn=admins,ou=groups,dc=grafana,dc=org" +org_role = "Admin" +# The Grafana organization database id, optional, if left out the default org (id 1) will be used +# org_id = 1 + +[[servers.group_mappings]] +group_dn = "cn=editors,ou=groups,dc=grafana,dc=org" +org_role = "Editor" + +[[servers.group_mappings]] +# If you want to match all (or no ldap groups) then you can use wildcard +group_dn = "*" +org_role = "Viewer" diff --git a/docker/blocks/openldap/notes.md b/docker/blocks/openldap/notes.md index 483266f0d88..65155423616 100644 --- a/docker/blocks/openldap/notes.md +++ b/docker/blocks/openldap/notes.md @@ -1,6 +1,6 @@ # Notes on OpenLdap Docker Block -Any ldif files added to the prepopulate subdirectory will be automatically imported into the OpenLdap database. +Any ldif files added to the prepopulate subdirectory will be automatically imported into the OpenLdap database. The ldif files add three users, `ldapviewer`, `ldapeditor` and `ldapadmin`. Two groups, `admins` and `users`, are added that correspond with the group mappings in the default conf/ldap.toml. `ldapadmin` is a member of `admins` and `ldapeditor` is a member of `users`. @@ -14,11 +14,32 @@ After adding ldif files to `prepopulate`: ## Enabling LDAP in Grafana -The default `ldap.toml` file in `conf` has host set to `127.0.0.1` and port to set to 389 so all you need to do is enable it in the .ini file to get Grafana to use this block: +Copy the ldap_dev.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block: ```ini [auth.ldap] enabled = true -config_file = conf/ldap.toml +config_file = conf/ldap_dev.toml ; allow_sign_up = true ``` + +Test groups & users + +admins + ldap-admin + ldap-torkel + ldap-daniel +backend + ldap-carl + ldap-torkel + ldap-leo +frontend + ldap-torkel + ldap-tobias + ldap-daniel +editors + ldap-editors + + +no groups + ldap-viewer diff --git a/docker/blocks/openldap/prepopulate.sh b/docker/blocks/openldap/prepopulate.sh new file mode 100755 index 00000000000..aa11f8aba4f --- /dev/null +++ b/docker/blocks/openldap/prepopulate.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "Pre-populating ldap entries, first waiting for ldap to start" + +sleep 3 + +adminUserDn="cn=admin,dc=grafana,dc=org" +adminPassword="grafana" + +for file in `ls /etc/ldap/prepopulate/*.ldif`; do + ldapadd -x -D $adminUserDn -w $adminPassword -f "$file" +done + + diff --git a/docker/blocks/openldap/prepopulate/1_units.ldif b/docker/blocks/openldap/prepopulate/1_units.ldif new file mode 100644 index 00000000000..22e06303688 --- /dev/null +++ b/docker/blocks/openldap/prepopulate/1_units.ldif @@ -0,0 +1,9 @@ +dn: ou=groups,dc=grafana,dc=org +ou: Groups +objectclass: top +objectclass: organizationalUnit + +dn: ou=users,dc=grafana,dc=org +ou: Users +objectclass: top +objectclass: organizationalUnit diff --git a/docker/blocks/openldap/prepopulate/2_users.ldif b/docker/blocks/openldap/prepopulate/2_users.ldif new file mode 100644 index 00000000000..52e74b1e4b1 --- /dev/null +++ b/docker/blocks/openldap/prepopulate/2_users.ldif @@ -0,0 +1,80 @@ +# ldap-admin +dn: cn=ldap-admin,ou=users,dc=grafana,dc=org +mail: ldap-admin@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-admin +cn: ldap-admin + +dn: cn=ldap-editor,ou=users,dc=grafana,dc=org +mail: ldap-editor@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-editor +cn: ldap-editor + +dn: cn=ldap-viewer,ou=users,dc=grafana,dc=org +mail: ldap-viewer@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-viewer +cn: ldap-viewer + +dn: cn=ldap-carl,ou=users,dc=grafana,dc=org +mail: ldap-carl@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-carl +cn: ldap-carl + +dn: cn=ldap-daniel,ou=users,dc=grafana,dc=org +mail: ldap-daniel@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-daniel +cn: ldap-daniel + +dn: cn=ldap-leo,ou=users,dc=grafana,dc=org +mail: ldap-leo@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-leo +cn: ldap-leo + +dn: cn=ldap-tobias,ou=users,dc=grafana,dc=org +mail: ldap-tobias@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-tobias +cn: ldap-tobias + +dn: cn=ldap-torkel,ou=users,dc=grafana,dc=org +mail: ldap-torkel@grafana.com +userPassword: grafana +objectClass: person +objectClass: top +objectClass: inetOrgPerson +objectClass: organizationalPerson +sn: ldap-torkel +cn: ldap-torkel diff --git a/docker/blocks/openldap/prepopulate/3_groups.ldif b/docker/blocks/openldap/prepopulate/3_groups.ldif new file mode 100644 index 00000000000..8638a089cc8 --- /dev/null +++ b/docker/blocks/openldap/prepopulate/3_groups.ldif @@ -0,0 +1,25 @@ +dn: cn=admins,ou=groups,dc=grafana,dc=org +cn: admins +objectClass: groupOfNames +objectClass: top +member: cn=ldap-admin,ou=users,dc=grafana,dc=org +member: cn=ldap-torkel,ou=users,dc=grafana,dc=org + +dn: cn=editors,ou=groups,dc=grafana,dc=org +cn: editors +objectClass: groupOfNames +member: cn=ldap-editor,ou=users,dc=grafana,dc=org + +dn: cn=backend,ou=groups,dc=grafana,dc=org +cn: backend +objectClass: groupOfNames +member: cn=ldap-carl,ou=users,dc=grafana,dc=org +member: cn=ldap-leo,ou=users,dc=grafana,dc=org +member: cn=ldap-torkel,ou=users,dc=grafana,dc=org + +dn: cn=frontend,ou=groups,dc=grafana,dc=org +cn: frontend +objectClass: groupOfNames +member: cn=ldap-torkel,ou=users,dc=grafana,dc=org +member: cn=ldap-daniel,ou=users,dc=grafana,dc=org +member: cn=ldap-leo,ou=users,dc=grafana,dc=org diff --git a/docker/blocks/openldap/prepopulate/admin.ldif b/docker/blocks/openldap/prepopulate/admin.ldif deleted file mode 100644 index 3f4406d5810..00000000000 --- a/docker/blocks/openldap/prepopulate/admin.ldif +++ /dev/null @@ -1,10 +0,0 @@ -dn: cn=ldapadmin,dc=grafana,dc=org -mail: ldapadmin@grafana.com -userPassword: grafana -objectClass: person -objectClass: top -objectClass: inetOrgPerson -objectClass: organizationalPerson -sn: ldapadmin -cn: ldapadmin -memberOf: cn=admins,dc=grafana,dc=org diff --git a/docker/blocks/openldap/prepopulate/adminsgroup.ldif b/docker/blocks/openldap/prepopulate/adminsgroup.ldif deleted file mode 100644 index d8dece4e458..00000000000 --- a/docker/blocks/openldap/prepopulate/adminsgroup.ldif +++ /dev/null @@ -1,5 +0,0 @@ -dn: cn=admins,dc=grafana,dc=org -cn: admins -member: cn=ldapadmin,dc=grafana,dc=org -objectClass: groupOfNames -objectClass: top diff --git a/docker/blocks/openldap/prepopulate/editor.ldif b/docker/blocks/openldap/prepopulate/editor.ldif deleted file mode 100644 index eba3adc4352..00000000000 --- a/docker/blocks/openldap/prepopulate/editor.ldif +++ /dev/null @@ -1,10 +0,0 @@ -dn: cn=ldapeditor,dc=grafana,dc=org -mail: ldapeditor@grafana.com -userPassword: grafana -objectClass: person -objectClass: top -objectClass: inetOrgPerson -objectClass: organizationalPerson -sn: ldapeditor -cn: ldapeditor -memberOf: cn=users,dc=grafana,dc=org diff --git a/docker/blocks/openldap/prepopulate/usersgroup.ldif b/docker/blocks/openldap/prepopulate/usersgroup.ldif deleted file mode 100644 index a1de3a50d38..00000000000 --- a/docker/blocks/openldap/prepopulate/usersgroup.ldif +++ /dev/null @@ -1,5 +0,0 @@ -dn: cn=users,dc=grafana,dc=org -cn: users -member: cn=ldapeditor,dc=grafana,dc=org -objectClass: groupOfNames -objectClass: top diff --git a/docker/blocks/openldap/prepopulate/viewer.ldif b/docker/blocks/openldap/prepopulate/viewer.ldif deleted file mode 100644 index f699a7df57b..00000000000 --- a/docker/blocks/openldap/prepopulate/viewer.ldif +++ /dev/null @@ -1,9 +0,0 @@ -dn: cn=ldapviewer,dc=grafana,dc=org -mail: ldapviewer@grafana.com -userPassword: grafana -objectClass: person -objectClass: top -objectClass: inetOrgPerson -objectClass: organizationalPerson -sn: ldapviewer -cn: ldapviewer diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index d178c176602..7adc6ebe4fb 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -115,6 +115,8 @@ and `dimension keys/values`. In place of `region` you can specify `default` to use the default region configured in the datasource for the query, e.g. `metrics(AWS/DynamoDB, default)` or `dimension_values(default, ..., ..., ...)`. +Read more about the available dimensions in the [CloudWatch Metrics and Dimensions Reference](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CW_Support_For_AWS.html). + Name | Description ------- | -------- *regions()* | Returns a list of regions AWS provides their service. diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 31ce78f0bfe..80a2f9a828a 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -58,8 +58,8 @@ a time pattern for the index name or a wildcard. ### Elasticsearch version -Be sure to specify your Elasticsearch version in the version selection dropdown. This is very important as there are differences how queries are composed. Currently only 2.x and 5.x -are supported. +Be sure to specify your Elasticsearch version in the version selection dropdown. This is very important as there are differences how queries are composed. +Currently the versions available is 2.x, 5.x and 5.6+ where 5.6+ means a version of 5.6 or higher, 6.3.2 for example. ### Min time interval A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example `1m` if your data is written every minute. @@ -115,7 +115,7 @@ The Elasticsearch data source supports two types of queries you can use in the * Query | Description ------------ | ------------- -*{"find": "fields", "type": "keyword"} | Returns a list of field names with the index type `keyword`. +*{"find": "fields", "type": "keyword"}* | Returns a list of field names with the index type `keyword`. *{"find": "terms", "field": "@hostname", "size": 1000}* | Returns a list of values for a field using term aggregation. Query will user current dashboard time range as time range for query. *{"find": "terms", "field": "@hostname", "query": ''}* | Returns a list of values for a field using term aggregation & and a specified lucene query filter. Query will use current dashboard time range as time range for query. diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md index 1676cffa0a8..da0c9581e99 100644 --- a/docs/sources/features/datasources/mssql.md +++ b/docs/sources/features/datasources/mssql.md @@ -77,14 +77,19 @@ Macro example | Description ------------ | ------------- *$__time(dateColumn)* | Will be replaced by an expression to rename the column to *time*. For example, *dateColumn as time* *$__timeEpoch(dateColumn)* | Will be replaced by an expression to convert a DATETIME column type to unix timestamp and rename it to *time*.
For example, *DATEDIFF(second, '1970-01-01', dateColumn) AS time* -*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name.
For example, *dateColumn >= DATEADD(s, 1494410783, '1970-01-01') AND dateColumn <= DATEADD(s, 1494410783, '1970-01-01')* -*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *DATEADD(second, 1494410783, '1970-01-01')* -*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *DATEADD(second, 1494410783, '1970-01-01')* +*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name.
For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'* +*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'* +*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'* *$__timeGroup(dateColumn,'5m'[, fillvalue])* | Will be replaced by an expression usable in GROUP BY clause. Providing a *fillValue* of *NULL* or *floating value* will automatically fill empty series in timerange with that value.
For example, *CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)\*300*. -*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so all null values will be converted to the fill value (all null values would be set to zero using this example). +*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value. +*$__timeGroup(dateColumn,'5m', NULL)* | Same as above but NULL will be used as value for missing points. +*$__timeGroup(dateColumn,'5m', previous)* | Same as above but the previous value in that series will be used as fill value if no value has been seen yet NULL will be used (only available in Grafana 5.3+). +*$__timeGroupAlias(dateColumn,'5m')* | Will be replaced identical to $__timeGroup but with an added column alias (only available in Grafana 5.3+). *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* +*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+). +*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo. @@ -148,7 +153,8 @@ The resulting table panel: ## Time series queries -If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must must have a column named `time` that returns either a sql datetime or any numeric datatype representing unix epoch in seconds. You may return a column named `metric` that is used as metric name for the value column. Any column except `time` and `metric` is treated as a value column. If you omit the `metric` column, tha name of the value column will be the metric name. You may select multiple value columns, each will have its name as metric. +If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must must have a column named `time` that returns either a sql datetime or any numeric datatype representing unix epoch in seconds. You may return a column named `metric` that is used as metric name for the value column. Any column except `time` and `metric` is treated as a value column. If you omit the `metric` column, the name of the value column will be the metric name. You may select multiple value columns, each will have its name as metric. +If you return multiple value columns and a column named `metric` then this column is used as prefix for the series name (only available in Grafana 5.3+). **Example database table:** diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index f91417a43b7..afac746b050 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -60,14 +60,19 @@ Macro example | Description ------------ | ------------- *$__time(dateColumn)* | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, *UNIX_TIMESTAMP(dateColumn) as time_sec* *$__timeEpoch(dateColumn)* | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, *UNIX_TIMESTAMP(dateColumn) as time_sec* -*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn > FROM_UNIXTIME(1494410783) AND dateColumn < FROM_UNIXTIME(1494497183)* -*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *FROM_UNIXTIME(1494410783)* -*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *FROM_UNIXTIME(1494497183)* +*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'* +*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'* +*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'* *$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) as signed)*300 as signed),* -*$__timeGroup(dateColumn,'5m',0)* | Same as above but with a fill parameter so all null values will be converted to the fill value (all null values would be set to zero using this example). +*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value. +*$__timeGroup(dateColumn,'5m', NULL)* | Same as above but NULL will be used as value for missing points. +*$__timeGroup(dateColumn,'5m', previous)* | Same as above but the previous value in that series will be used as fill value if no value has been seen yet NULL will be used (only available in Grafana 5.3+). +*$__timeGroupAlias(dateColumn,'5m')* | Will be replaced identical to $__timeGroup but with an added column alias (only available in Grafana 5.3+). *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* +*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+). +*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo. @@ -104,6 +109,7 @@ The resulting table panel: If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must return a column named `time` that returns either a sql datetime or any numeric datatype representing unix epoch. Any column except `time` and `metric` is treated as a value column. You may return a column named `metric` that is used as metric name for the value column. +If you return multiple value columns and a column named `metric` then this column is used as prefix for the series name (only available in Grafana 5.3+). **Example with `metric` column:** diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index f9af60a2efc..1d195a01349 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/docs/sources/features/datasources/postgres.md @@ -31,6 +31,7 @@ Name | Description *User* | Database user's login/username *Password* | Database user's password *SSL Mode* | This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. +*TimescaleDB* | With this option enabled Grafana will use TimescaleDB features, e.g. use ```time_bucket``` for grouping by time (only available in Grafana 5.3+). ### Database User Permissions (Important!) @@ -60,11 +61,16 @@ Macro example | Description *$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'* *$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'* *$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'* -*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from dateColumn)/300)::bigint*300 AS time* -*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so all null values will be converted to the fill value (all null values would be set to zero using this example). +*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from dateColumn)/300)::bigint*300* +*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value. +*$__timeGroup(dateColumn,'5m', NULL)* | Same as above but NULL will be used as value for missing points. +*$__timeGroup(dateColumn,'5m', previous)* | Same as above but the previous value in that series will be used as fill value if no value has been seen yet NULL will be used (only available in Grafana 5.3+). +*$__timeGroupAlias(dateColumn,'5m')* | Will be replaced identical to $__timeGroup but with an added column alias (only available in Grafana 5.3+). *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* +*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+). +*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo. @@ -102,6 +108,7 @@ The resulting table panel: If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must return a column named `time` that returns either a sql datetime or any numeric datatype representing unix epoch. Any column except `time` and `metric` is treated as a value column. You may return a column named `metric` that is used as metric name for the value column. +If you return multiple value columns and a column named `metric` then this column is used as prefix for the series name (only available in Grafana 5.3+). **Example with `metric` column:** @@ -285,4 +292,5 @@ datasources: password: "Password!" jsonData: sslmode: "disable" # disable/require/verify-ca/verify-full + timescaledb: false ``` diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index 4ff0baee108..611a3b4d9e2 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -75,6 +75,32 @@ Name | Description For details of *metric names*, *label names* and *label values* are please refer to the [Prometheus documentation](http://prometheus.io/docs/concepts/data_model/#metric-names-and-labels). + +#### Using interval and range variables + +> Support for `$__range`, `$__range_s` and `$__range_ms` only available from Grafana v5.3 + +It's possible to use some global built-in variables in query variables; `$__interval`, `$__interval_ms`, `$__range`, `$__range_s` and `$__range_ms`, see [Global built-in variables](/reference/templating/#global-built-in-variables) for more information. These can be convenient to use in conjunction with the `query_result` function when you need to filter variable queries since +`label_values` function doesn't support queries. + +Make sure to set the variable's `refresh` trigger to be `On Time Range Change` to get the correct instances when changing the time range on the dashboard. + +**Example usage:** + +Populate a variable with the the busiest 5 request instances based on average QPS over the time range shown in the dashboard: + +``` +Query: query_result(topk(5, sum(rate(http_requests_total[$__range])) by (instance))) +Regex: /"([^"]+)"/ +``` + +Populate a variable with the instances having a certain state over the time range shown in the dashboard, using the more precise `$__range_s`: + +``` +Query: query_result(max_over_time([${__range_s}s]) != ) +Regex: +``` + ### Using variables in queries There are two syntaxes: diff --git a/docs/sources/guides/basic_concepts.md b/docs/sources/guides/basic_concepts.md index b710a227a79..d3f8dd0ba63 100644 --- a/docs/sources/guides/basic_concepts.md +++ b/docs/sources/guides/basic_concepts.md @@ -54,7 +54,7 @@ We utilize a unit abstraction so that Grafana looks great on all screens both sm > Note: With MaxDataPoint functionality, Grafana can show you the perfect amount of datapoints no matter your resolution or time-range. -Utilize the [Repeating Row functionality](/reference/templating/#utilizing-template-variables-with-repeating-panels-and-repeating-rows) to dynamically create or remove entire Rows (that can be filled with Panels), based on the Template variables selected. +Utilize the [Repeating Rows functionality](/reference/templating/#repeating-rows) to dynamically create or remove entire Rows (that can be filled with Panels), based on the Template variables selected. Rows can be collapsed by clicking on the Row Title. If you save a Dashboard with a Row collapsed, it will save in that state and will not preload those graphs until the row is expanded. @@ -72,7 +72,7 @@ Panels like the [Graph](/reference/graph/) panel allow you to graph as many metr Panels can be made more dynamic by utilizing [Dashboard Templating](/reference/templating/) variable strings within the panel configuration (including queries to your Data Source configured via the Query Editor). -Utilize the [Repeating Panel](/reference/templating/#utilizing-template-variables-with-repeating-panels-and-repeating-rows) functionality to dynamically create or remove Panels based on the [Templating Variables](/reference/templating/#utilizing-template-variables-with-repeating-panels-and-repeating-rows) selected. +Utilize the [Repeating Panel](/reference/templating/#repeating-panels) functionality to dynamically create or remove Panels based on the [Templating Variables](/reference/templating/#repeating-panels) selected. The time range on Panels is normally what is set in the [Dashboard time picker](/reference/timerange/) but this can be overridden by utilizes [Panel specific time overrides](/reference/timerange/#panel-time-overrides-timeshift). diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 0194c69caac..2d4be21bb78 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -36,11 +36,10 @@ HTTP/1.1 200 Content-Type: application/json { -"DEFAULT": -{ - "app_mode":"production"}, - "analytics": - { + "DEFAULT": { + "app_mode":"production" + }, + "analytics": { "google_analytics_ua_id":"", "reporting_enabled":"false" }, @@ -195,15 +194,16 @@ HTTP/1.1 200 Content-Type: application/json { - "user_count":2, - "org_count":1, - "dashboard_count":4, - "db_snapshot_count":2, - "db_tag_count":6, - "data_source_count":1, - "playlist_count":1, - "starred_db_count":2, - "grafana_admin_count":2 + "users":2, + "orgs":1, + "dashboards":4, + "snapshots":2, + "tags":6, + "datasources":1, + "playlists":1, + "stars":2, + "alerts":2, + "activeUsers":1 } ``` @@ -340,4 +340,4 @@ HTTP/1.1 200 Content-Type: application/json {state: "new state", message: "alerts pause/un paused", "alertsAffected": 100} -``` \ No newline at end of file +``` diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index e4fe0dad3ff..80b6e283be3 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -59,7 +59,6 @@ Content-Type: application/json "panelId": 1, "name": "fire place sensor", "state": "alerting", - "message": "Someone is trying to break in through the fire place", "newStateDate": "2018-05-14T05:55:20+02:00", "evalDate": "0001-01-01T00:00:00Z", "evalData": null, diff --git a/docs/sources/http_api/dashboard.md b/docs/sources/http_api/dashboard.md index ea1bd7f2ef7..3df36894901 100644 --- a/docs/sources/http_api/dashboard.md +++ b/docs/sources/http_api/dashboard.md @@ -85,7 +85,7 @@ Status Codes: - **403** – Access denied - **412** – Precondition failed -The **412** status code is used for explaing that you cannot create the dashboard and why. +The **412** status code is used for explaining that you cannot create the dashboard and why. There can be different reasons for this: - The dashboard has been changed by someone else, `status=version-mismatch` diff --git a/docs/sources/http_api/folder.md b/docs/sources/http_api/folder.md index 7ee1f737799..e8845c3b125 100644 --- a/docs/sources/http_api/folder.md +++ b/docs/sources/http_api/folder.md @@ -19,6 +19,10 @@ The unique identifier (uid) of a folder can be used for uniquely identify folder The uid can have a maximum length of 40 characters. +## A note about the General folder + +The General folder (id=0) is special and is not part of the Folder API which means +that you cannot use this API for retrieving information about the General folder. ## Get all folders @@ -219,7 +223,7 @@ Status Codes: - **404** – Folder not found - **412** – Precondition failed -The **412** status code is used for explaing that you cannot update the folder and why. +The **412** status code is used for explaining that you cannot update the folder and why. There can be different reasons for this: - The folder has been changed by someone else, `status=version-mismatch` @@ -273,14 +277,14 @@ Status Codes: ## Get folder by id -`GET /api/folders/:id` +`GET /api/folders/id/:id` Will return the folder identified by id. **Example Request**: ```http -GET /api/folders/1 HTTP/1.1 +GET /api/folders/id/1 HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index 1ebde0878e7..c55107d42f8 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -12,7 +12,13 @@ parent = "http_api" # Organisation API -## Get current Organisation +The Organisation HTTP API is divided in two resources, `/api/org` (current organisation) +and `/api/orgs` (admin organisations). One big difference between these are that +the admin of all organisations API only works with basic authentication, see [Admin Organisations API](#admin-organisations-api) for more information. + +## Current Organisation API + +### Get current Organisation `GET /api/org/` @@ -37,135 +43,7 @@ Content-Type: application/json } ``` -## Get Organisation by Id - -`GET /api/orgs/:orgId` - -**Example Request**: - -```http -GET /api/orgs/1 HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` -Note: The api will only work when you pass the admin name and password -to the request http url, like http://admin:admin@localhost:3000/api/orgs/1 - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id":1, - "name":"Main Org.", - "address":{ - "address1":"", - "address2":"", - "city":"", - "zipCode":"", - "state":"", - "country":"" - } -} -``` -## Get Organisation by Name - -`GET /api/orgs/name/:orgName` - -**Example Request**: - -```http -GET /api/orgs/name/Main%20Org%2E HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk -``` -Note: The api will only work when you pass the admin name and password -to the request http url, like http://admin:admin@localhost:3000/api/orgs/name/Main%20Org%2E - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "id":1, - "name":"Main Org.", - "address":{ - "address1":"", - "address2":"", - "city":"", - "zipCode":"", - "state":"", - "country":"" - } -} -``` - -## Create Organisation - -`POST /api/orgs` - -**Example Request**: - -```http -POST /api/orgs HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "name":"New Org." -} -``` -Note: The api will work in the following two ways -1) Need to set GF_USERS_ALLOW_ORG_CREATE=true -2) Set the config users.allow_org_create to true in ini file - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{ - "orgId":"1", - "message":"Organization created" -} -``` - - -## Update current Organisation - -`PUT /api/org` - -**Example Request**: - -```http -PUT /api/org HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "name":"Main Org." -} -``` - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{"message":"Organization updated"} -``` - -## Get all users within the actual organisation +### Get all users within the current organisation `GET /api/org/users` @@ -195,36 +73,7 @@ Content-Type: application/json ] ``` -## Add a new user to the actual organisation - -`POST /api/org/users` - -Adds a global user to the actual organisation. - -**Example Request**: - -```http -POST /api/org/users HTTP/1.1 -Accept: application/json -Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - -{ - "role": "Admin", - "loginOrEmail": "admin" -} -``` - -**Example Response**: - -```http -HTTP/1.1 200 -Content-Type: application/json - -{"message":"User added to organization"} -``` - -## Updates the given user +### Updates the given user `PATCH /api/org/users/:userId` @@ -250,7 +99,7 @@ Content-Type: application/json {"message":"Organization user updated"} ``` -## Delete user in actual organisation +### Delete user in current organisation `DELETE /api/org/users/:userId` @@ -272,19 +121,181 @@ Content-Type: application/json {"message":"User removed from organization"} ``` -# Organisations +### Update current Organisation -## Search all Organisations +`PUT /api/org` + +**Example Request**: + +```http +PUT /api/org HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "name":"Main Org." +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Organization updated"} +``` + +### Add a new user to the current organisation + +`POST /api/org/users` + +Adds a global user to the current organisation. + +**Example Request**: + +```http +POST /api/org/users HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "role": "Admin", + "loginOrEmail": "admin" +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"User added to organization"} +``` + +## Admin Organisations API + +The Admin Organisations HTTP API does not currently work with an API Token. API Tokens are currently +only linked to an organization and an organization role. They cannot be given the permission of server +admin, only users can be given that permission. So in order to use these API calls you will have to +use Basic Auth and the Grafana user must have the Grafana Admin permission (The default admin user +is called `admin` and has permission to use this API). + +### Get Organisation by Id + +`GET /api/orgs/:orgId` + +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + +**Example Request**: + +```http +GET /api/orgs/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id":1, + "name":"Main Org.", + "address":{ + "address1":"", + "address2":"", + "city":"", + "zipCode":"", + "state":"", + "country":"" + } +} +``` +### Get Organisation by Name + +`GET /api/orgs/name/:orgName` + +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + +**Example Request**: + +```http +GET /api/orgs/name/Main%20Org%2E HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id":1, + "name":"Main Org.", + "address":{ + "address1":"", + "address2":"", + "city":"", + "zipCode":"", + "state":"", + "country":"" + } +} +``` + +### Create Organisation + +`POST /api/orgs` + +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + +**Example Request**: + +```http +POST /api/orgs HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "name":"New Org." +} +``` +Note: The api will work in the following two ways +1) Need to set GF_USERS_ALLOW_ORG_CREATE=true +2) Set the config users.allow_org_create to true in ini file + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "orgId":"1", + "message":"Organization created" +} +``` + +### Search all Organisations `GET /api/orgs` +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + **Example Request**: ```http GET /api/orgs HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk ``` Note: The api will only work when you pass the admin name and password to the request http url, like http://admin:admin@localhost:3000/api/orgs @@ -303,11 +314,12 @@ Content-Type: application/json ] ``` -## Update Organisation +### Update Organisation `PUT /api/orgs/:orgId` Update Organisation, fields *Address 1*, *Address 2*, *City* are not implemented yet. +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). **Example Request**: @@ -315,7 +327,6 @@ Update Organisation, fields *Address 1*, *Address 2*, *City* are not implemented PUT /api/orgs/1 HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "name":"Main Org 2." @@ -331,16 +342,17 @@ Content-Type: application/json {"message":"Organization updated"} ``` -## Delete Organisation +### Delete Organisation `DELETE /api/orgs/:orgId` +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + **Example Request**: ```http DELETE /api/orgs/1 HTTP/1.1 Accept: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk ``` **Example Response**: @@ -352,17 +364,18 @@ Content-Type: application/json {"message":"Organization deleted"} ``` -## Get Users in Organisation +### Get Users in Organisation `GET /api/orgs/:orgId/users` +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + **Example Request**: ```http GET /api/orgs/1/users HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk ``` Note: The api will only work when you pass the admin name and password to the request http url, like http://admin:admin@localhost:3000/api/orgs/1/users @@ -384,25 +397,24 @@ Content-Type: application/json ] ``` -## Add User in Organisation +### Add User in Organisation `POST /api/orgs/:orgId/users` +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + **Example Request**: ```http POST /api/orgs/1/users HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "loginOrEmail":"user", "role":"Viewer" } ``` -Note: The api will only work when you pass the admin name and password -to the request http url, like http://admin:admin@localhost:3000/api/orgs/1/users **Example Response**: @@ -413,17 +425,18 @@ Content-Type: application/json {"message":"User added to organization"} ``` -## Update Users in Organisation +### Update Users in Organisation `PATCH /api/orgs/:orgId/users/:userId` +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + **Example Request**: ```http PATCH /api/orgs/1/users/2 HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "role":"Admin" @@ -439,17 +452,18 @@ Content-Type: application/json {"message":"Organization user updated"} ``` -## Delete User in Organisation +### Delete User in Organisation `DELETE /api/orgs/:orgId/users/:userId` +Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). + **Example Request**: ```http DELETE /api/orgs/1/users/2 HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk ``` **Example Response**: diff --git a/docs/sources/http_api/playlist.md b/docs/sources/http_api/playlist.md new file mode 100644 index 00000000000..7c33900969b --- /dev/null +++ b/docs/sources/http_api/playlist.md @@ -0,0 +1,286 @@ ++++ +title = "Playlist HTTP API " +description = "Playlist Admin HTTP API" +keywords = ["grafana", "http", "documentation", "api", "playlist"] +aliases = ["/http_api/playlist/"] +type = "docs" +[menu.docs] +name = "Playlist" +parent = "http_api" ++++ + +# Playlist API + +## Search Playlist + +`GET /api/playlists` + +Get all existing playlist for the current organization using pagination + +**Example Request**: + +```bash +GET /api/playlists HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + + Querystring Parameters: + + These parameters are used as querystring parameters. + + - **query** - Limit response to playlist having a name like this value. + - **limit** - Limit response to *X* number of playlist. + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": 1, + "name": "my playlist", + "interval": "5m" + } +] +``` + +## Get one playlist + +`GET /api/playlists/:id` + +**Example Request**: + +```bash +GET /api/playlists/1 HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json +{ + "id" : 1, + "name": "my playlist", + "interval": "5m", + "orgId": "my org", + "items": [ + { + "id": 1, + "playlistId": 1, + "type": "dashboard_by_id", + "value": "3", + "order": 1, + "title":"my third dasboard" + }, + { + "id": 2, + "playlistId": 1, + "type": "dashboard_by_tag", + "value": "myTag", + "order": 2, + "title":"my other dasboard" + } + ] +} +``` + +## Get Playlist items + +`GET /api/playlists/:id/items` + +**Example Request**: + +```bash +GET /api/playlists/1/items HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": 1, + "playlistId": 1, + "type": "dashboard_by_id", + "value": "3", + "order": 1, + "title":"my third dasboard" + }, + { + "id": 2, + "playlistId": 1, + "type": "dashboard_by_tag", + "value": "myTag", + "order": 2, + "title":"my other dasboard" + } +] +``` + +## Get Playlist dashboards + +`GET /api/playlists/:id/dashboards` + +**Example Request**: + +```bash +GET /api/playlists/1/dashboards HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": 3, + "title": "my third dasboard", + "order": 1, + }, + { + "id": 5, + "title":"my other dasboard" + "order": 2, + + } +] +``` + +## Create a playlist + +`POST /api/playlists/` + +**Example Request**: + +```bash +PUT /api/playlists/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + { + "name": "my playlist", + "interval": "5m", + "items": [ + { + "type": "dashboard_by_id", + "value": "3", + "order": 1, + "title":"my third dasboard" + }, + { + "type": "dashboard_by_tag", + "value": "myTag", + "order": 2, + "title":"my other dasboard" + } + ] + } +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json + { + "id": 1, + "name": "my playlist", + "interval": "5m" + } +``` + +## Update a playlist + +`PUT /api/playlists/:id` + +**Example Request**: + +```bash +PUT /api/playlists/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + { + "name": "my playlist", + "interval": "5m", + "items": [ + { + "playlistId": 1, + "type": "dashboard_by_id", + "value": "3", + "order": 1, + "title":"my third dasboard" + }, + { + "playlistId": 1, + "type": "dashboard_by_tag", + "value": "myTag", + "order": 2, + "title":"my other dasboard" + } + ] + } +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json +{ + "id" : 1, + "name": "my playlist", + "interval": "5m", + "orgId": "my org", + "items": [ + { + "id": 1, + "playlistId": 1, + "type": "dashboard_by_id", + "value": "3", + "order": 1, + "title":"my third dasboard" + }, + { + "id": 2, + "playlistId": 1, + "type": "dashboard_by_tag", + "value": "myTag", + "order": 2, + "title":"my other dasboard" + } + ] +} +``` + +## Delete a playlist + +`DELETE /api/playlists/:id` + +**Example Request**: + +```bash +DELETE /api/playlists/1 HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json +{} +``` diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 134c1842851..b9047187b2d 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -363,6 +363,39 @@ Content-Type: application/json ] ``` +## Teams that the actual User is member of + +`GET /api/user/teams` + +Return a list of all teams that the current user is member of. + +**Example Request**: + +```http +GET /api/user/teams HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 1, + "orgId": 1, + "name": "MyTestTeam", + "email": "", + "avatarUrl": "\/avatar\/3f49c15916554246daa714b9bd0ee398", + "memberCount": 1 + } +] +``` + ## Star a dashboard `POST /api/user/stars/dashboard/:dashboardId` diff --git a/docs/sources/installation/behind_proxy.md b/docs/sources/installation/behind_proxy.md index 89711aecb46..6e3884456ac 100644 --- a/docs/sources/installation/behind_proxy.md +++ b/docs/sources/installation/behind_proxy.md @@ -26,7 +26,7 @@ Otherwise Grafana will not behave correctly. See example below. ## Examples Here are some example configurations for running Grafana behind a reverse proxy. -### Grafana configuration (ex http://foo.bar.com) +### Grafana configuration (ex http://foo.bar) ```bash [server] @@ -47,7 +47,7 @@ server { } ``` -### Examples with **sub path** (ex http://foo.bar.com/grafana) +### Examples with **sub path** (ex http://foo.bar/grafana) #### Grafana configuration with sub path ```bash diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 668a44fcb2b..4b14829b689 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -15,6 +15,8 @@ weight = 1 The Grafana back-end has a number of configuration options that can be specified in a `.ini` configuration file or specified using environment variables. +> **Note.** Grafana needs to be restarted for any configuration changes to take effect. + ## Comments In .ini Files Semicolons (the `;` char) are the standard way to comment out lines in a `.ini` file. @@ -82,7 +84,7 @@ command line in the init.d script or the systemd service file. ### temp_data_lifetime -How long temporary images in `data` directory should be kept. Defaults to: `24h`. Supported modifiers: `h` (hours), +How long temporary images in `data` directory should be kept. Defaults to: `24h`. Supported modifiers: `h` (hours), `m` (minutes), for example: `168h`, `30m`, `10h30m`. Use `0` to never clean up temporary files. ### logs @@ -179,7 +181,7 @@ embedded database (included in the main Grafana binary). ### url -Use either URL or or the other fields below to configure the database +Use either URL or the other fields below to configure the database Example: `mysql://user:secret@host:port/database` ### type @@ -193,9 +195,9 @@ will be stored. ### host -Only applicable to MySQL or Postgres. Includes IP or hostname and port. +Only applicable to MySQL or Postgres. Includes IP or hostname and port or in case of unix sockets the path to it. For example, for MySQL running on the same host as Grafana: `host = -127.0.0.1:3306` +127.0.0.1:3306` or with unix sockets: `host = /var/run/mysqld/mysqld.sock` ### name @@ -296,6 +298,12 @@ Set to `true` to automatically add new users to the main organization (id 1). When set to `false`, new users will automatically cause a new organization to be created for that new user. +### auto_assign_org_id + +Set this value to automatically add new users to the provided org. +This requires `auto_assign_org` to be set to `true`. Please make sure +that this organization does already exists. + ### auto_assign_org_role The role new users will be assigned for the main organization (if the @@ -422,6 +430,108 @@ allowed_organizations = github google
+## [auth.gitlab] + +> Only available in Grafana v5.3+. + +You need to [create a GitLab OAuth +application](https://docs.gitlab.com/ce/integration/oauth_provider.html). +Choose a descriptive *Name*, and use the following *Redirect URI*: + +``` +https://grafana.example.com/login/gitlab +``` + +where `https://grafana.example.com` is the URL you use to connect to Grafana. +Adjust it as needed if you don't use HTTPS or if you use a different port; for +instance, if you access Grafana at `http://203.0.113.31:3000`, you should use + +``` +http://203.0.113.31:3000/login/gitlab +``` + +Finally, select *api* as the *Scope* and submit the form. Note that if you're +not going to use GitLab groups for authorization (i.e. not setting +`allowed_groups`, see below), you can select *read_user* instead of *api* as +the *Scope*, thus giving a more restricted access to your GitLab API. + +You'll get an *Application Id* and a *Secret* in return; we'll call them +`GITLAB_APPLICATION_ID` and `GITLAB_SECRET` respectively for the rest of this +section. + +Add the following to your Grafana configuration file to enable GitLab +authentication: + +```ini +[auth.gitlab] +enabled = false +allow_sign_up = false +client_id = GITLAB_APPLICATION_ID +client_secret = GITLAB_SECRET +scopes = api +auth_url = https://gitlab.com/oauth/authorize +token_url = https://gitlab.com/oauth/token +api_url = https://gitlab.com/api/v4 +allowed_groups = +``` + +Restart the Grafana backend for your changes to take effect. + +If you use your own instance of GitLab instead of `gitlab.com`, adjust +`auth_url`, `token_url` and `api_url` accordingly by replacing the `gitlab.com` +hostname with your own. + +With `allow_sign_up` set to `false`, only existing users will be able to login +using their GitLab account, but with `allow_sign_up` set to `true`, *any* user +who can authenticate on GitLab will be able to login on your Grafana instance; +if you use the public `gitlab.com`, it means anyone in the world would be able +to login on your Grafana instance. + +You can can however limit access to only members of a given group or list of +groups by setting the `allowed_groups` option. + +### allowed_groups + +To limit access to authenticated users that are members of one or more [GitLab +groups](https://docs.gitlab.com/ce/user/group/index.html), set `allowed_groups` +to a comma- or space-separated list of groups. For instance, if you want to +only give access to members of the `example` group, set + + +```ini +allowed_groups = example +``` + +If you want to also give access to members of the subgroup `bar`, which is in +the group `foo`, set + +```ini +allowed_groups = example, foo/bar +``` + +Note that in GitLab, the group or subgroup name doesn't always match its +display name, especially if the display name contains spaces or special +characters. Make sure you always use the group or subgroup name as it appears +in the URL of the group or subgroup. + +Here's a complete example with `alloed_sign_up` enabled, and access limited to +the `example` and `foo/bar` groups: + +```ini +[auth.gitlab] +enabled = false +allow_sign_up = true +client_id = GITLAB_APPLICATION_ID +client_secret = GITLAB_SECRET +scopes = api +auth_url = https://gitlab.com/oauth/authorize +token_url = https://gitlab.com/oauth/token +api_url = https://gitlab.com/api/v4 +allowed_groups = example, foo/bar +``` + +
+ ## [auth.google] First, you need to create a Google OAuth Client: @@ -689,9 +799,9 @@ session provider you have configured. - **file:** session file path, e.g. `data/sessions` - **mysql:** go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1:3306)/database_name` -- **postgres:** ex: user=a password=b host=localhost port=5432 dbname=c sslmode=verify-full -- **memcache:** ex: 127.0.0.1:11211 -- **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana` +- **postgres:** ex: `user=a password=b host=localhost port=5432 dbname=c sslmode=verify-full` +- **memcache:** ex: `127.0.0.1:11211` +- **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`. For unix socket, use for example: `network=unix,addr=/var/run/redis/redis.sock,pool_size=100,db=grafana` Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default). @@ -857,7 +967,7 @@ Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Url to where Grafana will send PUT request with images ### public_url -Optional parameter. Url to send to users in notifications, directly appended with the resulting uploaded file name. +Optional parameter. Url to send to users in notifications. If the string contains the sequence ${file}, it will be replaced with the uploaded filename. Otherwise, the file name will be appended to the path part of the url, leaving any query string unchanged. ### username basic auth username diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index 1f755625699..6bf25ad8232 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -38,6 +38,8 @@ The back-end web server has a number of configuration options. Go to the [Configuration]({{< relref "configuration.md" >}}) page for details on all those options. +> For any changes to `conf/grafana.ini` (or corresponding environment variables) to take effect you need to restart Grafana by restarting the Docker container. + ## Running a Specific Version of Grafana ```bash @@ -49,10 +51,13 @@ $ docker run \ grafana/grafana:5.1.0 ``` -## Running of the master branch +## Running the master branch -For every successful commit we publish a Grafana container to [`grafana/grafana`](https://hub.docker.com/r/grafana/grafana/tags/) and [`grafana/grafana-dev`](https://hub.docker.com/r/grafana/grafana-dev/tags/). In `grafana/grafana` container we will always overwrite the `master` tag with the latest version. In `grafana/grafana-dev` we will include -the git commit in the tag. If you run Grafana master in production we **strongly** recommend that you use the later since different machines might run different version of grafana if they pull the master tag at different times. +For every successful build of the master branch we update the `grafana/grafana:master` tag and create a new tag `grafana/grafana-dev:master-` with the hash of the git commit that was built. This means you can always get the latest version of Grafana. + +When running Grafana master in production we **strongly** recommend that you use the `grafana/grafana-dev:master-` tag as that will guarantee that you use a specific version of Grafana instead of whatever was the most recent commit at the time. + +For a list of available tags, check out [grafana/grafana](https://hub.docker.com/r/grafana/grafana/tags/) and [grafana/grafana-dev](https://hub.docker.com/r/grafana/grafana-dev/tags/). ## Installing Plugins for Grafana diff --git a/docs/sources/installation/ldap.md b/docs/sources/installation/ldap.md index 85501e51d85..88cf40632db 100644 --- a/docs/sources/installation/ldap.md +++ b/docs/sources/installation/ldap.md @@ -23,8 +23,9 @@ specific configuration file (default: `/etc/grafana/ldap.toml`). ### Example config ```toml -# Set to true to log user information returned from LDAP -verbose_logging = false +# To troubleshoot and get more log info enable ldap debug logging in grafana.ini +# [log] +# filters = ldap:debug [[servers]] # Ldap server host (specify multiple hosts space separated) @@ -39,6 +40,9 @@ start_tls = false ssl_skip_verify = false # set to the path to your root CA certificate or leave unset to use system defaults # root_ca_cert = "/path/to/certificate.crt" +# Authentication against LDAP servers requiring client certificates +# client_cert = "/path/to/client.crt" +# client_key = "/path/to/client.key" # Search user bind dn bind_dn = "cn=admin,dc=grafana,dc=org" @@ -47,6 +51,7 @@ bind_dn = "cn=admin,dc=grafana,dc=org" bind_password = 'grafana' # User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" +# Allow login from email or username, example "(|(sAMAccountName=%s)(userPrincipalName=%s))" search_filter = "(cn=%s)" # An array of base dns to search through @@ -73,6 +78,8 @@ email = "email" [[servers.group_mappings]] group_dn = "cn=admins,dc=grafana,dc=org" org_role = "Admin" +# To make user an instance admin (Grafana Admin) uncomment line below +# grafana_admin = true # The Grafana organization database id, optional, if left out the default org (id 1) will be used. Setting this allows for multiple group_dn's to be assigned to the same org_role provided the org_id differs # org_id = 1 @@ -132,6 +139,10 @@ Users page, this change will be reset the next time the user logs in. If you change the LDAP groups of a user, the change will take effect the next time the user logs in. +### Grafana Admin +with a servers.group_mappings section you can set grafana_admin = true or false to sync Grafana Admin permission. A Grafana server admin has admin access over all orgs & +users. + ### Priority The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the TOML config will be used. diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index a9a7b5053c3..5dc87984512 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -19,6 +19,8 @@ installation. ## Configure +**Important:** After you've downloaded the zip file and before extracting it, make sure to open properties for that file (right-click Properties) and check the `unblock` checkbox and `Ok`. + The zip file contains a folder with the current Grafana version. Extract this folder to anywhere you want Grafana to run from. Go into the `conf` directory and copy `sample.ini` to `custom.ini`. You should edit diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index a0b553594ce..08673404572 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -57,7 +57,7 @@ For this you need nodejs (v.6+). ```bash npm install -g yarn yarn install --pure-lockfile -npm run watch +yarn watch ``` ## Running Grafana Locally @@ -83,21 +83,18 @@ go get github.com/Unknwon/bra bra run ``` -You'll also need to run `npm run watch` to watch for changes to the front-end (typescript, html, sass) +You'll also need to run `yarn watch` to watch for changes to the front-end (typescript, html, sass) ### Running tests -- You can run backend Golang tests using "go test ./pkg/...". -- Execute all frontend tests with "npm run test" +- You can run backend Golang tests using `go test ./pkg/...`. +- Execute all frontend tests with `yarn test` -Writing & watching frontend tests (we have two test runners) +Writing & watching frontend tests + +- Start watcher: `yarn jest` +- Jest will run all test files that end with the name ".test.ts" -- jest for all new tests that do not require browser context (React+more) - - Start watcher: `npm run jest` - - Jest will run all test files that end with the name ".jest.ts" -- karma + mocha is used for testing angularjs components. We do want to migrate these test to jest over time (if possible). - - Start watcher: `npm run karma` - - Karma+Mocha runs all files that end with the name "_specs.ts". ## Creating optimized release packages diff --git a/docs/sources/reference/scripting.md b/docs/sources/reference/scripting.md index 551805b567a..7f218765d39 100644 --- a/docs/sources/reference/scripting.md +++ b/docs/sources/reference/scripting.md @@ -21,42 +21,32 @@ If you open scripted.js you can see how it reads url parameters from ARGS variab ## Example ```javascript -var rows = 1; var seriesName = 'argName'; -if(!_.isUndefined(ARGS.rows)) { - rows = parseInt(ARGS.rows, 10); -} - if(!_.isUndefined(ARGS.name)) { seriesName = ARGS.name; } -for (var i = 0; i < rows; i++) { - - dashboard.rows.push({ - title: 'Scripted Graph ' + i, - height: '300px', - panels: [ - { - title: 'Events', - type: 'graph', - span: 12, - fill: 1, - linewidth: 2, - targets: [ - { - 'target': "randomWalk('" + seriesName + "')" - }, - { - 'target': "randomWalk('random walk2')" - } - ], - } - ] - }); - -} +dashboard.panels.push({ + title: 'Events', + type: 'graph', + fill: 1, + linewidth: 2, + gridPos: { + h: 10, + w: 24, + x: 0, + y: 10, + }, + targets: [ + { + 'target': "randomWalk('" + seriesName + "')" + }, + { + 'target': "randomWalk('random walk2')" + } + ] +}); return dashboard; ``` diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 8341b9770bd..7f86465312c 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -11,7 +11,7 @@ weight = 1 # Variables Variables allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application -and sensor name in you metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of +and sensor name in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. {{< docs-imagebox img="/img/docs/v50/variables_dashboard.png" >}} @@ -273,29 +273,49 @@ The `$__timeFilter` is used in the MySQL data source. This variable is only available in the Singlestat panel and can be used in the prefix or suffix fields on the Options tab. The variable will be replaced with the series name or alias. +### The $__range Variable + +> Only available in Grafana v5.3+ + +Currently only supported for Prometheus data sources. This variable represents the range for the current dashboard. It is calculated by `to - from`. It has a millisecond and a second representation called `$__range_ms` and `$__range_s`. + ## Repeating Panels Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want Grafana to dynamically create new panels or rows based on what values you have selected you can use the *Repeat* feature. -If you have a variable with `Multi-value` or `Include all value` options enabled you can choose one panel or one row and have Grafana repeat that row -for every selected value. You find this option under the General tab in panel edit mode. Select the variable to repeat by, and a `min span`. -The `min span` controls how small Grafana will make the panels (if you have many values selected). Grafana will automatically adjust the width of -each repeated panel so that the whole row is filled. Currently, you cannot mix other panels on a row with a repeated panel. +If you have a variable with `Multi-value` or `Include all value` options enabled you can choose one panel and have Grafana repeat that panel +for every selected value. You find the *Repeat* feature under the *General tab* in panel edit mode. + +The `direction` controls how the panels will be arranged. + +By choosing `horizontal` the panels will be arranged side-by-side. Grafana will automatically adjust the width +of each repeated panel so that the whole row is filled. Currently, you cannot mix other panels on a row with a repeated +panel. Each panel will never be smaller that the provided `Min width` if you have many selected values. + +By choosing `vertical` the panels will be arranged from top to bottom in a column. The `Min width` doesn't have any effect in this case. The width of the repeated panels will be the same as of the first panel (the original template) being repeated. Only make changes to the first panel (the original template). To have the changes take effect on all panels you need to trigger a dynamic dashboard re-build. You can do this by either changing the variable value (that is the basis for the repeat) or reload the dashboard. ## Repeating Rows -This option requires you to open the row options view. Hover over the row left side to trigger the row menu, in this menu click `Row Options`. This -opens the row options view. Here you find a *Repeat* dropdown where you can select the variable to repeat by. +As seen above with the *Panels* you can also repeat *Rows* if you have variables set with `Multi-value` or +`Include all value` selection option. -### URL state +To enable this feature you need to first add a new *Row* using the *Add Panel* menu. Then by hovering the row title and +clicking on the cog button, you will access the `Row Options` configuration panel. You can then select the variable +you want to repeat the row for. + +It may be a good idea to use a variable in the row title as well. + +Example: [Repeated Rows Dashboard](http://play.grafana.org/dashboard/db/repeated-rows) + +## URL state Variable values are always synced to the URL using the syntax `var-=value`. -### Examples +## Examples - [Graphite Templated Dashboard](http://play.grafana.org/dashboard/db/graphite-templated-nested) - [Elasticsearch Templated Dashboard](http://play.grafana.org/dashboard/db/elasticsearch-templated) diff --git a/jest.config.js b/jest.config.js index 606465c9840..a5cd3416f75 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,7 +13,7 @@ module.exports = { "roots": [ "/public" ], - "testRegex": "(\\.|/)(jest)\\.(jsx?|tsx?)$", + "testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$", "moduleFileExtensions": [ "ts", "tsx", diff --git a/karma.conf.js b/karma.conf.js deleted file mode 100644 index 352e8e4e027..00000000000 --- a/karma.conf.js +++ /dev/null @@ -1,40 +0,0 @@ -var webpack = require('webpack'); -var path = require('path'); -var webpackTestConfig = require('./scripts/webpack/webpack.test.js'); - -module.exports = function(config) { - - 'use strict'; - - config.set({ - frameworks: ['mocha', 'expect', 'sinon'], - - // list of files / patterns to load in the browser - files: [ - { pattern: 'public/test/index.ts', watched: false } - ], - - preprocessors: { - 'public/test/index.ts': ['webpack', 'sourcemap'], - }, - - webpack: webpackTestConfig, - webpackMiddleware: { - stats: 'minimal', - }, - - // list of files to exclude - exclude: [], - reporters: ['dots'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['PhantomJS'], - captureTimeout: 20000, - singleRun: true, - // autoWatchBatchDelay: 1000, - // browserNoActivityTimeout: 60000, - }); - -}; diff --git a/package.json b/package.json index 4f2220abbae..8520def5db8 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,8 @@ "es6-shim": "^0.35.3", "expect.js": "~0.2.0", "expose-loader": "^0.7.3", - "extract-text-webpack-plugin": "^4.0.0-beta.0", "file-loader": "^1.1.11", - "fork-ts-checker-webpack-plugin": "^0.4.1", + "fork-ts-checker-webpack-plugin": "^0.4.2", "gaze": "^1.1.2", "glob": "~7.0.0", "grunt": "1.0.1", @@ -45,10 +44,7 @@ "grunt-contrib-concat": "^1.0.1", "grunt-contrib-copy": "~1.0.0", "grunt-contrib-cssmin": "~1.0.2", - "grunt-contrib-jshint": "~1.1.0", "grunt-exec": "^1.0.1", - "grunt-jscs": "3.0.1", - "grunt-karma": "~2.0.0", "grunt-notify": "^0.4.5", "grunt-postcss": "^0.8.0", "grunt-sass": "^2.0.0", @@ -60,23 +56,16 @@ "html-webpack-plugin": "^3.2.0", "husky": "^0.14.3", "jest": "^22.0.4", - "jshint-stylish": "~2.2.1", - "karma": "1.7.0", - "karma-chrome-launcher": "~2.2.0", - "karma-expect": "~1.1.3", - "karma-mocha": "~1.3.0", - "karma-phantomjs-launcher": "1.0.4", - "karma-sinon": "^1.0.5", - "karma-sourcemap-loader": "^0.3.7", - "karma-webpack": "^3.0.0", "lint-staged": "^6.0.0", "load-grunt-tasks": "3.5.2", + "mini-css-extract-plugin": "^0.4.0", "mobx-react-devtools": "^4.2.15", "mocha": "^4.0.1", "ng-annotate-loader": "^0.6.1", - "ng-annotate-webpack-plugin": "^0.2.1-pre", + "ng-annotate-webpack-plugin": "^0.3.0", "ngtemplate-loader": "^2.0.1", "npm": "^5.4.2", + "optimize-css-assets-webpack-plugin": "^4.0.2", "phantomjs-prebuilt": "^2.1.15", "postcss-browser-reporter": "^0.5.0", "postcss-loader": "^2.0.6", @@ -90,15 +79,16 @@ "style-loader": "^0.21.0", "systemjs": "0.20.19", "systemjs-plugin-css": "^0.1.36", - "ts-loader": "^4.3.0", "ts-jest": "^22.4.6", + "ts-loader": "^4.3.0", + "tslib": "^1.9.3", "tslint": "^5.8.0", "tslint-loader": "^3.5.3", "typescript": "^2.6.2", + "uglifyjs-webpack-plugin": "^1.2.7", "webpack": "^4.8.0", "webpack-bundle-analyzer": "^2.9.0", "webpack-cleanup-plugin": "^0.5.1", - "fork-ts-checker-webpack-plugin": "^0.4.2", "webpack-cli": "^2.1.4", "webpack-dev-server": "^3.1.0", "webpack-merge": "^4.1.0", @@ -112,7 +102,6 @@ "test": "grunt test", "test:coverage": "grunt test --coverage=true", "lint": "tslint -c tslint.json --project tsconfig.json --type-check", - "karma": "grunt karma:dev", "jest": "jest --notify --watch", "api-tests": "jest --notify --watch --config=tests/api/jest.js", "precommit": "lint-staged && grunt precommit" @@ -149,22 +138,21 @@ "classnames": "^2.2.5", "clipboard": "^1.7.1", "d3": "^4.11.0", - "d3-scale-chromatic": "^1.1.1", + "d3-scale-chromatic": "^1.3.0", "eventemitter3": "^2.0.3", "file-saver": "^1.3.3", "immutable": "^3.8.2", "jquery": "^3.2.1", - "lodash": "^4.17.4", - "mini-css-extract-plugin": "^0.4.0", + "lodash": "^4.17.10", "mobx": "^3.4.1", "mobx-react": "^4.3.5", "mobx-state-tree": "^1.3.1", - "moment": "^2.18.1", + "moment": "^2.22.2", "mousetrap": "^1.6.0", "mousetrap-global-bind": "^1.1.0", - "optimize-css-assets-webpack-plugin": "^4.0.2", "prismjs": "^1.6.0", "prop-types": "^15.6.0", + "rc-cascader": "^0.14.0", "react": "^16.2.0", "react-dom": "^16.2.0", "react-grid-layout": "0.16.6", @@ -181,8 +169,7 @@ "slate-react": "^0.12.4", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop/tarball/master", - "tinycolor2": "^1.4.1", - "uglifyjs-webpack-plugin": "^1.2.7" + "tinycolor2": "^1.4.1" }, "resolutions": { "caniuse-db": "1.0.30000772" diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile new file mode 100644 index 00000000000..890d6a4fb11 --- /dev/null +++ b/packaging/docker/Dockerfile @@ -0,0 +1,52 @@ +FROM debian:stretch-slim + +ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz" + +RUN apt-get update && apt-get install -qq -y tar && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz + +RUN mkdir /tmp/grafana && tar xfvz /tmp/grafana.tar.gz --strip-components=1 -C /tmp/grafana + +FROM debian:stretch-slim + +ARG GF_UID="472" +ARG GF_GID="472" + +ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + +WORKDIR $GF_PATHS_HOME + +RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=0 /tmp/grafana "$GF_PATHS_HOME" + +RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ + groupadd -r -g $GF_GID grafana && \ + useradd -r -u $GF_UID -g grafana grafana && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ + cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ + chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" && \ + chmod 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" + +EXPOSE 3000 + +COPY ./run.sh /run.sh + +USER grafana +ENTRYPOINT [ "/run.sh" ] diff --git a/packaging/docker/README.md b/packaging/docker/README.md new file mode 100644 index 00000000000..cfb3c7248ef --- /dev/null +++ b/packaging/docker/README.md @@ -0,0 +1,43 @@ +# Grafana Docker image + +## Running your Grafana container + +Start your container binding the external port `3000`. + +```bash +docker run -d --name=grafana -p 3000:3000 grafana/grafana +``` + +Try it out, default admin user is admin/admin. + +## How to use the container + +Further documentation can be found at http://docs.grafana.org/installation/docker/ + +## Changelog + +### v5.1.5, v5.2.0-beta2 +* Fix: config keys ending with _FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170) + +### v5.2.0-beta1 +* Support for Docker Secrets + +### v5.1.0 +* Major restructuring of the container +* Usage of `chown` removed +* File permissions incompatibility with previous versions + * user id changed from 104 to 472 + * group id changed from 107 to 472 +* Runs as the grafana user by default (instead of root) +* All default volumes removed + +### v4.2.0 +* Plugins are now installed into ${GF_PATHS_PLUGINS} +* Building the container now requires a full url to the deb package instead of just version +* Fixes bug caused by installing multiple plugins + +### v4.0.0-beta2 +* Plugins dir (`/var/lib/grafana/plugins`) is no longer a separate volume + +### v3.1.1 +* Make it possible to install specific plugin version https://github.com/grafana/grafana-docker/issues/59#issuecomment-260584026 diff --git a/packaging/docker/build-deploy.sh b/packaging/docker/build-deploy.sh new file mode 100755 index 00000000000..ac3226a4a61 --- /dev/null +++ b/packaging/docker/build-deploy.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +_grafana_version=$1 +./build.sh "$_grafana_version" +docker login -u "$DOCKER_USER" -p "$DOCKER_PASS" + +./push_to_docker_hub.sh "$_grafana_version" + +if echo "$_grafana_version" | grep -q "^master-"; then + apk add --no-cache curl + ./deploy_to_k8s.sh "grafana/grafana-dev:$_grafana_version" +fi diff --git a/packaging/docker/build.sh b/packaging/docker/build.sh new file mode 100755 index 00000000000..c303c71cd5f --- /dev/null +++ b/packaging/docker/build.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +_grafana_tag=$1 + +# If the tag starts with v, treat this as a official release +if echo "$_grafana_tag" | grep -q "^v"; then + _grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2) + _docker_repo=${2:-grafana/grafana} +else + _grafana_version=$_grafana_tag + _docker_repo=${2:-grafana/grafana-dev} +fi + +echo "Building ${_docker_repo}:${_grafana_version}" + +docker build \ + --tag "${_docker_repo}:${_grafana_version}" \ + --no-cache=true . + +# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master +if echo "$_grafana_tag" | grep -q "^v"; then + docker tag "${_docker_repo}:${_grafana_version}" "${_docker_repo}:latest" +else + docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana:master" +fi diff --git a/packaging/docker/custom/Dockerfile b/packaging/docker/custom/Dockerfile new file mode 100644 index 00000000000..79eba5f29e9 --- /dev/null +++ b/packaging/docker/custom/Dockerfile @@ -0,0 +1,16 @@ +ARG GRAFANA_VERSION="latest" + +FROM grafana/grafana:${GRAFANA_VERSION} + +USER grafana + +ARG GF_INSTALL_PLUGINS="" + +RUN if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then \ + OLDIFS=$IFS; \ + IFS=','; \ + for plugin in ${GF_INSTALL_PLUGINS}; do \ + IFS=$OLDIFS; \ + grafana-cli --pluginsDir "$GF_PATHS_PLUGINS" plugins install ${plugin}; \ + done; \ +fi diff --git a/packaging/docker/deploy_to_k8s.sh b/packaging/docker/deploy_to_k8s.sh new file mode 100755 index 00000000000..26cf88ef688 --- /dev/null +++ b/packaging/docker/deploy_to_k8s.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +curl -s --header "Content-Type: application/json" \ + --data "{\"build_parameters\": {\"CIRCLE_JOB\": \"deploy\", \"IMAGE_NAMES\": \"$1\"}}" \ + --request POST \ + https://circleci.com/api/v1.1/project/github/raintank/deployment_tools/tree/master?circle-token=$CIRCLE_TOKEN diff --git a/packaging/docker/push_to_docker_hub.sh b/packaging/docker/push_to_docker_hub.sh new file mode 100755 index 00000000000..526c216f8fa --- /dev/null +++ b/packaging/docker/push_to_docker_hub.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +_grafana_tag=$1 + +# If the tag starts with v, treat this as a official release +if echo "$_grafana_tag" | grep -q "^v"; then + _grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2) + _docker_repo=${2:-grafana/grafana} +else + _grafana_version=$_grafana_tag + _docker_repo=${2:-grafana/grafana-dev} +fi + +echo "pushing ${_docker_repo}:${_grafana_version}" +docker push "${_docker_repo}:${_grafana_version}" + +if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"; then + echo "pushing ${_docker_repo}:latest" + docker push "${_docker_repo}:latest" +elif echo "$_grafana_tag" | grep -q "master"; then + echo "pushing grafana/grafana:master" + docker push grafana/grafana:master +fi diff --git a/packaging/docker/run.sh b/packaging/docker/run.sh new file mode 100755 index 00000000000..bc001bdf90a --- /dev/null +++ b/packaging/docker/run.sh @@ -0,0 +1,88 @@ +#!/bin/bash -e + +PERMISSIONS_OK=0 + +if [ ! -r "$GF_PATHS_CONFIG" ]; then + echo "GF_PATHS_CONFIG='$GF_PATHS_CONFIG' is not readable." + PERMISSIONS_OK=1 +fi + +if [ ! -w "$GF_PATHS_DATA" ]; then + echo "GF_PATHS_DATA='$GF_PATHS_DATA' is not writable." + PERMISSIONS_OK=1 +fi + +if [ ! -r "$GF_PATHS_HOME" ]; then + echo "GF_PATHS_HOME='$GF_PATHS_HOME' is not readable." + PERMISSIONS_OK=1 +fi + +if [ $PERMISSIONS_OK -eq 1 ]; then + echo "You may have issues with file permissions, more information here: http://docs.grafana.org/installation/docker/#migration-from-a-previous-version-of-the-docker-container-to-5-1-or-later" +fi + +if [ ! -d "$GF_PATHS_PLUGINS" ]; then + mkdir "$GF_PATHS_PLUGINS" +fi + +if [ ! -z ${GF_AWS_PROFILES+x} ]; then + > "$GF_PATHS_HOME/.aws/credentials" + + for profile in ${GF_AWS_PROFILES}; do + access_key_varname="GF_AWS_${profile}_ACCESS_KEY_ID" + secret_key_varname="GF_AWS_${profile}_SECRET_ACCESS_KEY" + region_varname="GF_AWS_${profile}_REGION" + + if [ ! -z "${!access_key_varname}" -a ! -z "${!secret_key_varname}" ]; then + echo "[${profile}]" >> "$GF_PATHS_HOME/.aws/credentials" + echo "aws_access_key_id = ${!access_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials" + echo "aws_secret_access_key = ${!secret_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials" + if [ ! -z "${!region_varname}" ]; then + echo "region = ${!region_varname}" >> "$GF_PATHS_HOME/.aws/credentials" + fi + fi + done + + chmod 600 "$GF_PATHS_HOME/.aws/credentials" +fi + +# Convert all environment variables with names ending in __FILE into the content of +# the file that they point at and use the name without the trailing __FILE. +# This can be used to carry in Docker secrets. +for VAR_NAME in $(env | grep '^GF_[^=]\+__FILE=.\+' | sed -r "s/([^=]*)__FILE=.*/\1/g"); do + VAR_NAME_FILE="$VAR_NAME"__FILE + if [ "${!VAR_NAME}" ]; then + echo >&2 "ERROR: Both $VAR_NAME and $VAR_NAME_FILE are set (but are exclusive)" + exit 1 + fi + echo "Getting secret $VAR_NAME from ${!VAR_NAME_FILE}" + export "$VAR_NAME"="$(< "${!VAR_NAME_FILE}")" + unset "$VAR_NAME_FILE" +done + +export HOME="$GF_PATHS_HOME" + +if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then + OLDIFS=$IFS + IFS=',' + for plugin in ${GF_INSTALL_PLUGINS}; do + IFS=$OLDIFS + if [[ $plugin =~ .*\;.* ]]; then + pluginUrl=$(echo "$plugin" | cut -d';' -f 1) + pluginWithoutUrl=$(echo "$plugin" | cut -d';' -f 2) + grafana-cli --pluginUrl "${pluginUrl}" --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${pluginWithoutUrl} + else + grafana-cli --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${plugin} + fi + done +fi + +exec grafana-server \ + --homepath="$GF_PATHS_HOME" \ + --config="$GF_PATHS_CONFIG" \ + "$@" \ + cfg:default.log.mode="console" \ + cfg:default.paths.data="$GF_PATHS_DATA" \ + cfg:default.paths.logs="$GF_PATHS_LOGS" \ + cfg:default.paths.plugins="$GF_PATHS_PLUGINS" \ + cfg:default.paths.provisioning="$GF_PATHS_PROVISIONING" diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go index abfdfb66322..331beeef5e4 100644 --- a/pkg/api/alerting_test.go +++ b/pkg/api/alerting_test.go @@ -31,7 +31,7 @@ func TestAlertingApiEndpoint(t *testing.T) { }) bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = []*m.Team{} + query.Result = []*m.TeamDTO{} return nil }) @@ -135,7 +135,7 @@ func postAlertScenario(desc string, url string, routePattern string, role m.Role defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index e5f63ce022b..08f3018c694 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -119,7 +119,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) { }) bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = []*m.Team{} + query.Result = []*m.TeamDTO{} return nil }) @@ -223,7 +223,7 @@ func postAnnotationScenario(desc string, url string, routePattern string, role m defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID @@ -246,7 +246,7 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m. defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID @@ -269,7 +269,7 @@ func deleteAnnotationsScenario(desc string, url string, routePattern string, rol defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID diff --git a/pkg/api/api.go b/pkg/api/api.go index 39dae56eb69..906481bbb8a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -9,9 +9,7 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -// Register adds http routes func (hs *HTTPServer) registerRoutes() { - macaronR := hs.macaron reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}) reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) @@ -21,15 +19,12 @@ func (hs *HTTPServer) registerRoutes() { quota := middleware.Quota bind := binding.Bind - // automatically set HEAD for every GET - macaronR.SetAutoHead(true) - r := hs.RouteRegister // not logged in views r.Get("/", reqSignedIn, Index) r.Get("/logout", Logout) - r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), wrap(LoginPost)) + r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost)) r.Get("/login/:name", quota("session"), OAuthLogin) r.Get("/login", LoginView) r.Get("/invite/:code", Index) @@ -78,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboards/", reqSignedIn, Index) r.Get("/dashboards/*", reqSignedIn, Index) - r.Get("/explore/", reqEditorRole, Index) - r.Get("/explore/*", reqEditorRole, Index) + r.Get("/explore", reqEditorRole, Index) r.Get("/playlists/", reqSignedIn, Index) r.Get("/playlists/*", reqSignedIn, Index) @@ -88,20 +82,20 @@ func (hs *HTTPServer) registerRoutes() { // sign up r.Get("/signup", Index) - r.Get("/api/user/signup/options", wrap(GetSignUpOptions)) - r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), wrap(SignUp)) - r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), wrap(SignUpStep2)) + r.Get("/api/user/signup/options", Wrap(GetSignUpOptions)) + r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp)) + r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2)) // invited - r.Get("/api/user/invite/:code", wrap(GetInviteInfoByCode)) - r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), wrap(CompleteInvite)) + r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode)) + r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite)) // reset password r.Get("/user/password/send-reset-email", Index) r.Get("/user/password/reset", Index) - r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), wrap(SendResetPasswordEmail)) - r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), wrap(ResetPassword)) + r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), Wrap(SendResetPasswordEmail)) + r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword)) // dashboard snapshots r.Get("/dashboard/snapshot/*", Index) @@ -111,8 +105,8 @@ func (hs *HTTPServer) registerRoutes() { r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", GetSharingOptions) r.Get("/api/snapshots/:key", GetDashboardSnapshot) - r.Get("/api/snapshots-delete/:deleteKey", wrap(DeleteDashboardSnapshotByDeleteKey)) - r.Delete("/api/snapshots/:key", reqEditorRole, wrap(DeleteDashboardSnapshot)) + r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey)) + r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) // api renew session based on remember cookie r.Get("/api/login/ping", quota("session"), LoginAPIPing) @@ -122,138 +116,139 @@ func (hs *HTTPServer) registerRoutes() { // user (signed in) apiRoute.Group("/user", func(userRoute routing.RouteRegister) { - userRoute.Get("/", wrap(GetSignedInUser)) - userRoute.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser)) - userRoute.Post("/using/:id", wrap(UserSetUsingOrg)) - userRoute.Get("/orgs", wrap(GetSignedInUserOrgList)) + userRoute.Get("/", Wrap(GetSignedInUser)) + userRoute.Put("/", bind(m.UpdateUserCommand{}), Wrap(UpdateSignedInUser)) + userRoute.Post("/using/:id", Wrap(UserSetUsingOrg)) + userRoute.Get("/orgs", Wrap(GetSignedInUserOrgList)) + userRoute.Get("/teams", Wrap(GetSignedInUserTeamList)) - userRoute.Post("/stars/dashboard/:id", wrap(StarDashboard)) - userRoute.Delete("/stars/dashboard/:id", wrap(UnstarDashboard)) + userRoute.Post("/stars/dashboard/:id", Wrap(StarDashboard)) + userRoute.Delete("/stars/dashboard/:id", Wrap(UnstarDashboard)) - userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), wrap(ChangeUserPassword)) - userRoute.Get("/quotas", wrap(GetUserQuotas)) - userRoute.Put("/helpflags/:id", wrap(SetHelpFlag)) + userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword)) + userRoute.Get("/quotas", Wrap(GetUserQuotas)) + userRoute.Put("/helpflags/:id", Wrap(SetHelpFlag)) // For dev purpose - userRoute.Get("/helpflags/clear", wrap(ClearHelpFlags)) + userRoute.Get("/helpflags/clear", Wrap(ClearHelpFlags)) - userRoute.Get("/preferences", wrap(GetUserPreferences)) - userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), wrap(UpdateUserPreferences)) + userRoute.Get("/preferences", Wrap(GetUserPreferences)) + userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences)) }) // users (admin permission required) apiRoute.Group("/users", func(usersRoute routing.RouteRegister) { - usersRoute.Get("/", wrap(SearchUsers)) - usersRoute.Get("/search", wrap(SearchUsersWithPaging)) - usersRoute.Get("/:id", wrap(GetUserByID)) - usersRoute.Get("/:id/orgs", wrap(GetUserOrgList)) + usersRoute.Get("/", Wrap(SearchUsers)) + usersRoute.Get("/search", Wrap(SearchUsersWithPaging)) + usersRoute.Get("/:id", Wrap(GetUserByID)) + usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com - usersRoute.Get("/lookup", wrap(GetUserByLoginOrEmail)) - usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), wrap(UpdateUser)) - usersRoute.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg)) + usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail)) + usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), Wrap(UpdateUser)) + usersRoute.Post("/:id/using/:orgId", Wrap(UpdateUserActiveOrg)) }, reqGrafanaAdmin) // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { - teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID)) - teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) + teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam)) + teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID)) + teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) + teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember)) }, reqOrgAdmin) // team without requirement of user to be org admin apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { - teamsRoute.Get("/:teamId", wrap(GetTeamByID)) - teamsRoute.Get("/search", wrap(SearchTeams)) + teamsRoute.Get("/:teamId", Wrap(GetTeamByID)) + teamsRoute.Get("/search", Wrap(SearchTeams)) }) // org information available to all users. apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - orgRoute.Get("/", wrap(GetOrgCurrent)) - orgRoute.Get("/quotas", wrap(GetOrgQuotas)) + orgRoute.Get("/", Wrap(GetOrgCurrent)) + orgRoute.Get("/quotas", Wrap(GetOrgQuotas)) }) // current org apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrgCurrent)) - orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddressCurrent)) - orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg)) - orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg)) - orgRoute.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg)) + orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrgCurrent)) + orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddressCurrent)) + orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg)) + orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg)) + orgRoute.Delete("/users/:userId", Wrap(RemoveOrgUserForCurrentOrg)) // invites - orgRoute.Get("/invites", wrap(GetPendingOrgInvites)) - orgRoute.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), wrap(AddOrgInvite)) - orgRoute.Patch("/invites/:code/revoke", wrap(RevokeInvite)) + orgRoute.Get("/invites", Wrap(GetPendingOrgInvites)) + orgRoute.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), Wrap(AddOrgInvite)) + orgRoute.Patch("/invites/:code/revoke", Wrap(RevokeInvite)) // prefs - orgRoute.Get("/preferences", wrap(GetOrgPreferences)) - orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), wrap(UpdateOrgPreferences)) + orgRoute.Get("/preferences", Wrap(GetOrgPreferences)) + orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateOrgPreferences)) }, reqOrgAdmin) // current org without requirement of user to be org admin apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg)) + orgRoute.Get("/users", Wrap(GetOrgUsersForCurrentOrg)) }) // create new org - apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg)) + apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), Wrap(CreateOrg)) // search all orgs - apiRoute.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs)) + apiRoute.Get("/orgs", reqGrafanaAdmin, Wrap(SearchOrgs)) // orgs (admin routes) apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) { - orgsRoute.Get("/", wrap(GetOrgByID)) - orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrg)) - orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddress)) - orgsRoute.Delete("/", wrap(DeleteOrgByID)) - orgsRoute.Get("/users", wrap(GetOrgUsers)) - orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUser)) - orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUser)) - orgsRoute.Delete("/users/:userId", wrap(RemoveOrgUser)) - orgsRoute.Get("/quotas", wrap(GetOrgQuotas)) - orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), wrap(UpdateOrgQuota)) + orgsRoute.Get("/", Wrap(GetOrgByID)) + orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrg)) + orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddress)) + orgsRoute.Delete("/", Wrap(DeleteOrgByID)) + orgsRoute.Get("/users", Wrap(GetOrgUsers)) + orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), Wrap(AddOrgUser)) + orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser)) + orgsRoute.Delete("/users/:userId", Wrap(RemoveOrgUser)) + orgsRoute.Get("/quotas", Wrap(GetOrgQuotas)) + orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota)) }, reqGrafanaAdmin) // orgs (admin routes) apiRoute.Group("/orgs/name/:name", func(orgsRoute routing.RouteRegister) { - orgsRoute.Get("/", wrap(GetOrgByName)) + orgsRoute.Get("/", Wrap(GetOrgByName)) }, reqGrafanaAdmin) // auth api keys apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { - keysRoute.Get("/", wrap(GetAPIKeys)) - keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddAPIKey)) - keysRoute.Delete("/:id", wrap(DeleteAPIKey)) + keysRoute.Get("/", Wrap(GetAPIKeys)) + keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), Wrap(AddAPIKey)) + keysRoute.Delete("/:id", Wrap(DeleteAPIKey)) }, reqOrgAdmin) // Preferences apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) { - prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), wrap(SetHomeDashboard)) + prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), Wrap(SetHomeDashboard)) }) // Data sources apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) { - datasourceRoute.Get("/", wrap(GetDataSources)) - datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), wrap(AddDataSource)) - datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource)) - datasourceRoute.Delete("/:id", wrap(DeleteDataSourceByID)) - datasourceRoute.Delete("/name/:name", wrap(DeleteDataSourceByName)) - datasourceRoute.Get("/:id", wrap(GetDataSourceByID)) - datasourceRoute.Get("/name/:name", wrap(GetDataSourceByName)) + datasourceRoute.Get("/", Wrap(GetDataSources)) + datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource)) + datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) + datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID)) + datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName)) + datasourceRoute.Get("/:id", Wrap(GetDataSourceByID)) + datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName)) }, reqOrgAdmin) - apiRoute.Get("/datasources/id/:name", wrap(GetDataSourceIDByName), reqSignedIn) + apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn) - apiRoute.Get("/plugins", wrap(GetPluginList)) - apiRoute.Get("/plugins/:pluginId/settings", wrap(GetPluginSettingByID)) - apiRoute.Get("/plugins/:pluginId/markdown/:name", wrap(GetPluginMarkdown)) + apiRoute.Get("/plugins", Wrap(GetPluginList)) + apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID)) + apiRoute.Get("/plugins/:pluginId/markdown/:name", Wrap(GetPluginMarkdown)) apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { - pluginRoute.Get("/:pluginId/dashboards/", wrap(GetPluginDashboards)) - pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), wrap(UpdatePluginSetting)) + pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards)) + pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) }, reqOrgAdmin) apiRoute.Get("/frontend/settings/", GetFrontendSettings) @@ -262,106 +257,106 @@ func (hs *HTTPServer) registerRoutes() { // Folders apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { - folderRoute.Get("/", wrap(GetFolders)) - folderRoute.Get("/id/:id", wrap(GetFolderByID)) - folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) + folderRoute.Get("/", Wrap(GetFolders)) + folderRoute.Get("/id/:id", Wrap(GetFolderByID)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", wrap(GetFolderByUID)) - folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) - folderUidRoute.Delete("/", wrap(DeleteFolder)) + folderUidRoute.Get("/", Wrap(GetFolderByUID)) + folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), Wrap(UpdateFolder)) + folderUidRoute.Delete("/", Wrap(DeleteFolder)) folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { - folderPermissionRoute.Get("/", wrap(GetFolderPermissionList)) - folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions)) + folderPermissionRoute.Get("/", Wrap(GetFolderPermissionList)) + folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), Wrap(UpdateFolderPermissions)) }) }) }) // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { - dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) - dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUID)) + dashboardRoute.Get("/uid/:uid", Wrap(GetDashboard)) + dashboardRoute.Delete("/uid/:uid", Wrap(DeleteDashboardByUID)) - dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) - dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard)) + dashboardRoute.Get("/db/:slug", Wrap(GetDashboard)) + dashboardRoute.Delete("/db/:slug", Wrap(DeleteDashboard)) - dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff)) + dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff)) - dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) - dashboardRoute.Get("/home", wrap(GetHomeDashboard)) + dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(PostDashboard)) + dashboardRoute.Get("/home", Wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) - dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) + dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), Wrap(ImportDashboard)) dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) { - dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) - dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) - dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + dashIdRoute.Get("/versions", Wrap(GetDashboardVersions)) + dashIdRoute.Get("/versions/:id", Wrap(GetDashboardVersion)) + dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), Wrap(RestoreDashboardVersion)) dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) { - dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList)) - dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions)) + dashboardPermissionRoute.Get("/", Wrap(GetDashboardPermissionList)) + dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), Wrap(UpdateDashboardPermissions)) }) }) }) // Dashboard snapshots apiRoute.Group("/dashboard/snapshots", func(dashboardRoute routing.RouteRegister) { - dashboardRoute.Get("/", wrap(SearchDashboardSnapshots)) + dashboardRoute.Get("/", Wrap(SearchDashboardSnapshots)) }) // Playlist apiRoute.Group("/playlists", func(playlistRoute routing.RouteRegister) { - playlistRoute.Get("/", wrap(SearchPlaylists)) - playlistRoute.Get("/:id", ValidateOrgPlaylist, wrap(GetPlaylist)) - playlistRoute.Get("/:id/items", ValidateOrgPlaylist, wrap(GetPlaylistItems)) - playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, wrap(GetPlaylistDashboards)) - playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, wrap(DeletePlaylist)) - playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, wrap(UpdatePlaylist)) - playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), wrap(CreatePlaylist)) + playlistRoute.Get("/", Wrap(SearchPlaylists)) + playlistRoute.Get("/:id", ValidateOrgPlaylist, Wrap(GetPlaylist)) + playlistRoute.Get("/:id/items", ValidateOrgPlaylist, Wrap(GetPlaylistItems)) + playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, Wrap(GetPlaylistDashboards)) + playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, Wrap(DeletePlaylist)) + playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist)) + playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), Wrap(CreatePlaylist)) }) // Search apiRoute.Get("/search/", Search) // metrics - apiRoute.Post("/tsdb/query", bind(dtos.MetricRequest{}), wrap(QueryMetrics)) - apiRoute.Get("/tsdb/testdata/scenarios", wrap(GetTestDataScenarios)) - apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, wrap(GenerateSQLTestData)) - apiRoute.Get("/tsdb/testdata/random-walk", wrap(GetTestDataRandomWalk)) + apiRoute.Post("/tsdb/query", bind(dtos.MetricRequest{}), Wrap(QueryMetrics)) + apiRoute.Get("/tsdb/testdata/scenarios", Wrap(GetTestDataScenarios)) + apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, Wrap(GenerateSQLTestData)) + apiRoute.Get("/tsdb/testdata/random-walk", Wrap(GetTestDataRandomWalk)) apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) { - alertsRoute.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest)) - alertsRoute.Post("/:alertId/pause", reqEditorRole, bind(dtos.PauseAlertCommand{}), wrap(PauseAlert)) - alertsRoute.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert)) - alertsRoute.Get("/", wrap(GetAlerts)) - alertsRoute.Get("/states-for-dashboard", wrap(GetAlertStatesForDashboard)) + alertsRoute.Post("/test", bind(dtos.AlertTestCommand{}), Wrap(AlertTest)) + alertsRoute.Post("/:alertId/pause", reqEditorRole, bind(dtos.PauseAlertCommand{}), Wrap(PauseAlert)) + alertsRoute.Get("/:alertId", ValidateOrgAlert, Wrap(GetAlert)) + alertsRoute.Get("/", Wrap(GetAlerts)) + alertsRoute.Get("/states-for-dashboard", Wrap(GetAlertStatesForDashboard)) }) - apiRoute.Get("/alert-notifications", wrap(GetAlertNotifications)) - apiRoute.Get("/alert-notifiers", wrap(GetAlertNotifiers)) + apiRoute.Get("/alert-notifications", Wrap(GetAlertNotifications)) + apiRoute.Get("/alert-notifiers", Wrap(GetAlertNotifiers)) apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) { - alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), wrap(NotificationTest)) - alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), wrap(CreateAlertNotification)) - alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), wrap(UpdateAlertNotification)) - alertNotifications.Get("/:notificationId", wrap(GetAlertNotificationByID)) - alertNotifications.Delete("/:notificationId", wrap(DeleteAlertNotification)) + alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), Wrap(NotificationTest)) + alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification)) + alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification)) + alertNotifications.Get("/:notificationId", Wrap(GetAlertNotificationByID)) + alertNotifications.Delete("/:notificationId", Wrap(DeleteAlertNotification)) }, reqEditorRole) - apiRoute.Get("/annotations", wrap(GetAnnotations)) - apiRoute.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations)) + apiRoute.Get("/annotations", Wrap(GetAnnotations)) + apiRoute.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), Wrap(DeleteAnnotations)) apiRoute.Group("/annotations", func(annotationsRoute routing.RouteRegister) { - annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation)) - annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationByID)) - annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation)) - annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion)) - annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) + annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation)) + annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID)) + annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation)) + annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion)) + annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation)) }) // error test - r.Get("/metrics/error", wrap(GenerateError)) + r.Get("/metrics/error", Wrap(GenerateError)) }, reqSignedIn) @@ -372,10 +367,10 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword) adminRoute.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions) adminRoute.Delete("/users/:id", AdminDeleteUser) - adminRoute.Get("/users/:id/quotas", wrap(GetUserQuotas)) - adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota)) + adminRoute.Get("/users/:id/quotas", Wrap(GetUserQuotas)) + adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) adminRoute.Get("/stats", AdminGetStats) - adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), wrap(PauseAllAlerts)) + adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts)) }, reqGrafanaAdmin) // rendering @@ -393,10 +388,4 @@ func (hs *HTTPServer) registerRoutes() { // streams //r.Post("/api/streams/push", reqSignedIn, bind(dtos.StreamMessage{}), liveConn.PushToStream) - - r.Register(macaronR) - - InitAppPluginRoutes(macaronR) - - macaronR.NotFound(NotFoundHandler) } diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 0b7dcd32ce3..a2137089fc6 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -18,7 +18,7 @@ import ( var pluginProxyTransport *http.Transport -func InitAppPluginRoutes(r *macaron.Macaron) { +func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) { pluginProxyTransport = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: setting.PluginAppsSkipVerifyTLS, diff --git a/pkg/api/common.go b/pkg/api/common.go index 97f41ff7c72..7973c72c8fa 100644 --- a/pkg/api/common.go +++ b/pkg/api/common.go @@ -30,7 +30,7 @@ type NormalResponse struct { err error } -func wrap(action interface{}) macaron.Handler { +func Wrap(action interface{}) macaron.Handler { return func(c *m.ReqContext) { var res Response diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 40c438b607a..8b66a7a468b 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -23,7 +23,7 @@ func loggedInUserScenarioWithRole(desc string, method string, url string, routeP defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.UserId = TestUserID sc.context.OrgId = TestOrgID @@ -51,7 +51,7 @@ func anonymousUserScenario(desc string, method string, url string, routePattern defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c if sc.handlerFunc != nil { return sc.handlerFunc(sc.context) diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index 24f0bdca365..f65c5f1f5fa 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -194,7 +194,7 @@ func updateDashboardPermissionScenario(desc string, url string, routePattern str sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.OrgId = TestOrgID sc.context.UserId = TestUserID diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 5e7637a24e1..e58f2c4712d 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -39,7 +39,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { return nil }) - teamResp := []*m.Team{} + teamResp := []*m.TeamDTO{} bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { query.Result = teamResp return nil diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index ccde2382787..283a9b5f12c 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -61,7 +61,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = []*m.Team{} + query.Result = []*m.TeamDTO{} return nil }) @@ -230,7 +230,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { - query.Result = []*m.Team{} + query.Result = []*m.TeamDTO{} return nil }) @@ -882,7 +882,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{OrgId: cmd.OrgId, UserId: cmd.UserId} @@ -907,7 +907,7 @@ func postDiffScenario(desc string, url string, routePattern string, cmd dtos.Cal defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{ OrgId: TestOrgID, diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 6ffefea991a..23dbb221d71 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -158,12 +158,26 @@ func UpdateDataSource(c *m.ReqContext, cmd m.UpdateDataSourceCommand) Response { } return Error(500, "Failed to update datasource", err) } - ds := convertModelToDtos(cmd.Result) + + query := m.GetDataSourceByIdQuery{ + Id: cmd.Id, + OrgId: c.OrgId, + } + + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrDataSourceNotFound { + return Error(404, "Data source not found", nil) + } + return Error(500, "Failed to query datasources", err) + } + + dtos := convertModelToDtos(query.Result) + return JSON(200, util.DynMap{ "message": "Datasource updated", "id": cmd.Id, "name": cmd.Name, - "datasource": ds, + "datasource": dtos, }) } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 8c7f505277d..77004899fc3 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -13,6 +13,7 @@ type IndexViewData struct { Theme string NewGrafanaVersionExists bool NewGrafanaVersion string + AppName string } type PluginCss struct { diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index f7458af6dce..64a746ca937 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -226,7 +226,7 @@ func updateFolderPermissionScenario(desc string, url string, routePattern string sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.OrgId = TestOrgID sc.context.UserId = TestUserID diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 0d9b9495686..6e24e432535 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -152,7 +152,7 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} @@ -181,7 +181,7 @@ func updateFolderScenario(desc string, url string, routePattern string, mock *fa defer bus.ClearBusHandlers() sc := setupScenarioContext(url) - sc.defaultHandler = wrap(func(c *m.ReqContext) Response { + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index e7272e68997..da3c88566c1 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -153,6 +153,7 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { "latestVersion": plugins.GrafanaLatestVersion, "hasUpdate": plugins.GrafanaHasUpdate, "env": setting.Env, + "isEnterprise": setting.IsEnterprise, }, } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index e1a10fb468f..0de63ce5e08 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -33,7 +33,11 @@ import ( ) func init() { - registry.RegisterService(&HTTPServer{}) + registry.Register(®istry.Descriptor{ + Name: "HTTPServer", + Instance: &HTTPServer{}, + InitPriority: registry.High, + }) } type HTTPServer struct { @@ -54,6 +58,10 @@ func (hs *HTTPServer) Init() error { hs.log = log.New("http.server") hs.cache = gocache.New(5*time.Minute, 10*time.Minute) + hs.streamManager = live.NewStreamManager() + hs.macaron = hs.newMacaron() + hs.registerRoutes() + return nil } @@ -61,10 +69,8 @@ func (hs *HTTPServer) Run(ctx context.Context) error { var err error hs.context = ctx - hs.streamManager = live.NewStreamManager() - hs.macaron = hs.newMacaron() - hs.registerRoutes() + hs.applyRoutes() hs.streamManager.Run(ctx) listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort) @@ -164,6 +170,26 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron { macaron.Env = setting.Env m := macaron.New() + // automatically set HEAD for every GET + m.SetAutoHead(true) + + return m +} + +func (hs *HTTPServer) applyRoutes() { + // start with middlewares & static routes + hs.addMiddlewaresAndStaticRoutes() + // then add view routes & api routes + hs.RouteRegister.Register(hs.macaron) + // then custom app proxy routes + hs.initAppPluginRoutes(hs.macaron) + // lastly not found route + hs.macaron.NotFound(NotFoundHandler) +} + +func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { + m := hs.macaron + m.Use(middleware.Logger()) if setting.EnableGzip { @@ -175,7 +201,7 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron { for _, route := range plugins.StaticRoutes { pluginRoute := path.Join("/public/plugins/", route.PluginId) hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory) - hs.mapStatic(m, route.Directory, "", pluginRoute) + hs.mapStatic(hs.macaron, route.Directory, "", pluginRoute) } hs.mapStatic(m, setting.StaticRootPath, "build", "public/build") @@ -204,8 +230,6 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron { } m.Use(middleware.AddDefaultResponseHeaders()) - - return m } func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { diff --git a/pkg/api/index.go b/pkg/api/index.go index a52bd3e77b0..ea10940d3ba 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -76,6 +76,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { BuildCommit: setting.BuildCommit, NewGrafanaVersion: plugins.GrafanaLatestVersion, NewGrafanaVersionExists: plugins.GrafanaHasUpdate, + AppName: setting.ApplicationName, } if setting.DisableGravatar { diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index c1b8ffe595e..f2bc79df7ad 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -52,7 +52,7 @@ func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response { if res.Error != nil { res.ErrorString = res.Error.Error() resp.Message = res.ErrorString - statusCode = 500 + statusCode = 400 } } @@ -99,7 +99,7 @@ func GetTestDataRandomWalk(c *m.ReqContext) Response { timeRange := tsdb.NewTimeRange(from, to) request := &tsdb.TsdbQuery{TimeRange: timeRange} - dsInfo := &m.DataSource{Type: "grafana-testdata-datasource"} + dsInfo := &m.DataSource{Type: "testdata"} request.Queries = append(request.Queries, &tsdb.Query{ RefId: "A", IntervalMs: intervalMs, diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index a90b6425cb6..0963df7d4c4 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response { func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response { cmd.OrgId = c.OrgId + cmd.Id = c.ParamsInt64(":id") if err := bus.Dispatch(&cmd); err != nil { return Error(500, "Failed to save playlist", err) diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index b420398f9a9..fb2cab9b9b1 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -203,6 +203,7 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { req.Header.Del("X-Forwarded-Host") req.Header.Del("X-Forwarded-Port") req.Header.Del("X-Forwarded-Proto") + req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion)) // set X-Forwarded-For header if req.RemoteAddr != "" { @@ -319,9 +320,15 @@ func (proxy *DataSourceProxy) applyRoute(req *http.Request) { SecureJsonData: proxy.ds.SecureJsonData.Decrypt(), } - routeURL, err := url.Parse(proxy.route.Url) + interpolatedURL, err := interpolateString(proxy.route.Url, data) if err != nil { - logger.Error("Error parsing plugin route url") + logger.Error("Error interpolating proxy url", "error", err) + return + } + + routeURL, err := url.Parse(interpolatedURL) + if err != nil { + logger.Error("Error parsing plugin route url", "error", err) return } diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index bb553b4d075..e6d05872787 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -49,6 +49,13 @@ func TestDSRouteRule(t *testing.T) { {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, }, + { + Path: "api/common", + Url: "{{.JsonData.dynamicUrl}}", + Headers: []plugins.AppPluginRouteHeader{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, + }, + }, }, } @@ -57,7 +64,8 @@ func TestDSRouteRule(t *testing.T) { ds := &m.DataSource{ JsonData: simplejson.NewFromAny(map[string]interface{}{ - "clientId": "asd", + "clientId": "asd", + "dynamicUrl": "https://dynamic.grafana.com", }), SecureJsonData: map[string][]byte{ "key": key, @@ -83,6 +91,17 @@ func TestDSRouteRule(t *testing.T) { }) }) + Convey("When matching route path and has dynamic url", func() { + proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method") + proxy.route = plugin.Routes[3] + proxy.applyRoute(req) + + Convey("should add headers and interpolate the url", func() { + So(req.URL.String(), ShouldEqual, "https://dynamic.grafana.com/some/method") + So(req.Header.Get("x-header"), ShouldEqual, "my secret 123") + }) + }) + Convey("Validating request", func() { Convey("plugin route with valid role", func() { proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method") @@ -212,20 +231,21 @@ func TestDSRouteRule(t *testing.T) { }) Convey("When proxying graphite", func() { + setting.BuildVersion = "5.3.0" plugin := &plugins.DataSourcePlugin{} ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE} ctx := &m.ReqContext{} proxy := NewDataSourceProxy(ds, plugin, ctx, "/render") + req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) + So(err, ShouldBeNil) - requestURL, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestURL} - - proxy.getDirector()(&req) + proxy.getDirector()(req) Convey("Can translate request url and path", func() { So(req.URL.Host, ShouldEqual, "graphite:8080") So(req.URL.Path, ShouldEqual, "/render") + So(req.Header.Get("User-Agent"), ShouldEqual, "Grafana/5.3.0") }) }) @@ -243,10 +263,10 @@ func TestDSRouteRule(t *testing.T) { ctx := &m.ReqContext{} proxy := NewDataSourceProxy(ds, plugin, ctx, "") - requestURL, _ := url.Parse("http://grafana.com/sub") - req := http.Request{URL: requestURL} + req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) + So(err, ShouldBeNil) - proxy.getDirector()(&req) + proxy.getDirector()(req) Convey("Should add db to url", func() { So(req.URL.Path, ShouldEqual, "/db/site/") diff --git a/pkg/api/team.go b/pkg/api/team.go index 9919305881b..ebb426c4c82 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -93,5 +93,6 @@ func GetTeamByID(c *m.ReqContext) Response { return Error(500, "Failed to get Team", err) } + query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name) return JSON(200, &query.Result) } diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 0bf06d723c8..a1984288870 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -13,7 +13,7 @@ import ( func TestTeamApiEndpoint(t *testing.T) { Convey("Given two teams", t, func() { mockResult := models.SearchTeamQueryResult{ - Teams: []*models.SearchTeamDto{ + Teams: []*models.TeamDTO{ {Name: "team1"}, {Name: "team2"}, }, diff --git a/pkg/api/user.go b/pkg/api/user.go index 725c623575f..4b916202e65 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -111,6 +111,21 @@ func GetSignedInUserOrgList(c *m.ReqContext) Response { return getUserOrgList(c.UserId) } +// GET /api/user/teams +func GetSignedInUserTeamList(c *m.ReqContext) Response { + query := m.GetTeamsByUserQuery{OrgId: c.OrgId, UserId: c.UserId} + + if err := bus.Dispatch(&query); err != nil { + return Error(500, "Failed to get user teams", err) + } + + for _, team := range query.Result { + team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) + } + + return JSON(200, query.Result) +} + // GET /api/user/:id/orgs func GetUserOrgList(c *m.ReqContext) Response { return getUserOrgList(c.ParamsInt64(":id")) diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index e64f42a7320..f00e6bba0fd 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -14,10 +14,11 @@ import ( "net/http" _ "net/http/pprof" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/setting" - _ "github.com/grafana/grafana/pkg/extensions" + extensions "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" _ "github.com/grafana/grafana/pkg/tsdb/cloudwatch" @@ -34,7 +35,6 @@ import ( var version = "5.0.0" var commit = "NA" var buildstamp string -var enterprise string var configFile = flag.String("config", "", "path to config file") var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory") @@ -77,7 +77,7 @@ func main() { setting.BuildVersion = version setting.BuildCommit = commit setting.BuildStamp = buildstampInt64 - setting.Enterprise, _ = strconv.ParseBool(enterprise) + setting.IsEnterprise = extensions.IsEnterprise metrics.M_Grafana_Version.WithLabelValues(version).Set(1) @@ -87,7 +87,11 @@ func main() { err := server.Run() - server.Exit(err) + code := server.Exit(err) + trace.Stop() + log.Close() + + os.Exit(code) } func listenToSystemSignals(server *GrafanaServerImpl) { diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index a4543ef1f20..8794d7d8338 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -175,7 +175,7 @@ func (g *GrafanaServerImpl) Shutdown(reason string) { g.childRoutines.Wait() } -func (g *GrafanaServerImpl) Exit(reason error) { +func (g *GrafanaServerImpl) Exit(reason error) int { // default exit code is 1 code := 1 @@ -185,9 +185,7 @@ func (g *GrafanaServerImpl) Exit(reason error) { } g.log.Error("Server shutdown", "reason", reason) - - log.Close() - os.Exit(code) + return code } func (g *GrafanaServerImpl) writePIDFile() { diff --git a/pkg/components/imguploader/webdavuploader.go b/pkg/components/imguploader/webdavuploader.go index f5478ea8a2f..ed6b14725c0 100644 --- a/pkg/components/imguploader/webdavuploader.go +++ b/pkg/components/imguploader/webdavuploader.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "path" + "strings" "time" "github.com/grafana/grafana/pkg/util" @@ -35,6 +36,16 @@ var netClient = &http.Client{ Transport: netTransport, } +func (u *WebdavUploader) PublicURL(filename string) string { + if strings.Contains(u.public_url, "${file}") { + return strings.Replace(u.public_url, "${file}", filename, -1) + } else { + publicURL, _ := url.Parse(u.public_url) + publicURL.Path = path.Join(publicURL.Path, filename) + return publicURL.String() + } +} + func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) { url, _ := url.Parse(u.url) filename := util.GetRandomString(20) + ".png" @@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) } if u.public_url != "" { - publicURL, _ := url.Parse(u.public_url) - publicURL.Path = path.Join(publicURL.Path, filename) - return publicURL.String(), nil + return u.PublicURL(filename), nil } return url.String(), nil diff --git a/pkg/components/imguploader/webdavuploader_test.go b/pkg/components/imguploader/webdavuploader_test.go index 5a8abd0542d..0178c9cda6c 100644 --- a/pkg/components/imguploader/webdavuploader_test.go +++ b/pkg/components/imguploader/webdavuploader_test.go @@ -2,6 +2,7 @@ package imguploader import ( "context" + "net/url" "testing" . "github.com/smartystreets/goconvey/convey" @@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) { So(path, ShouldStartWith, "http://publicurl:8888/webdav/") }) } + +func TestPublicURL(t *testing.T) { + Convey("Given a public URL with parameters, and no template", t, func() { + webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=") + parsed, _ := url.Parse(webdavUploader.PublicURL("fileyfile.png")) + So(parsed.Path, ShouldEndWith, "fileyfile.png") + }) + Convey("Given a public URL with parameters, and a template", t, func() { + webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=${file}") + So(webdavUploader.PublicURL("fileyfile.png"), ShouldEndWith, "fileyfile.png") + }) +} diff --git a/pkg/extensions/main.go b/pkg/extensions/main.go index 34ac9da7e86..6e3461da8a8 100644 --- a/pkg/extensions/main.go +++ b/pkg/extensions/main.go @@ -1,3 +1,3 @@ package extensions -import _ "github.com/pkg/errors" +var IsEnterprise bool = false diff --git a/pkg/login/ext_user.go b/pkg/login/ext_user.go index e1d5e3e3b48..a421e3ebe0a 100644 --- a/pkg/login/ext_user.go +++ b/pkg/login/ext_user.go @@ -21,6 +21,7 @@ func UpsertUser(cmd *m.UpsertUserCommand) error { Email: extUser.Email, Login: extUser.Login, } + err := bus.Dispatch(userQuery) if err != m.ErrUserNotFound && err != nil { return err @@ -66,7 +67,28 @@ func UpsertUser(cmd *m.UpsertUserCommand) error { } } - return syncOrgRoles(cmd.Result, extUser) + err = syncOrgRoles(cmd.Result, extUser) + if err != nil { + return err + } + + // Sync isGrafanaAdmin permission + if extUser.IsGrafanaAdmin != nil && *extUser.IsGrafanaAdmin != cmd.Result.IsAdmin { + if err := bus.Dispatch(&m.UpdateUserPermissionsCommand{UserId: cmd.Result.Id, IsGrafanaAdmin: *extUser.IsGrafanaAdmin}); err != nil { + return err + } + } + + err = bus.Dispatch(&m.SyncTeamsCommand{ + User: cmd.Result, + ExternalUser: extUser, + }) + + if err == bus.ErrHandlerNotFound { + return nil + } + + return err } func createUser(extUser *m.ExternalUserInfo) (*m.User, error) { @@ -76,6 +98,7 @@ func createUser(extUser *m.ExternalUserInfo) (*m.User, error) { Name: extUser.Name, SkipOrgSetup: len(extUser.OrgRoles) > 0, } + if err := bus.Dispatch(cmd); err != nil { return nil, err } diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 026a94fa43e..053778e8deb 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -59,6 +59,13 @@ func (a *ldapAuther) Dial() error { } } } + var clientCert tls.Certificate + if a.server.ClientCert != "" && a.server.ClientKey != "" { + clientCert, err = tls.LoadX509KeyPair(a.server.ClientCert, a.server.ClientKey) + if err != nil { + return err + } + } for _, host := range strings.Split(a.server.Host, " ") { address := fmt.Sprintf("%s:%d", host, a.server.Port) if a.server.UseSSL { @@ -67,6 +74,9 @@ func (a *ldapAuther) Dial() error { ServerName: host, RootCAs: certPool, } + if len(clientCert.Certificate) > 0 { + tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert) + } if a.server.StartTLS { a.conn, err = ldap.Dial("tcp", address) if err == nil { @@ -163,6 +173,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo Name: fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName), Login: ldapUser.Username, Email: ldapUser.Email, + Groups: ldapUser.MemberOf, OrgRoles: map[int64]m.RoleType{}, } @@ -174,6 +185,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo if ldapUser.isMemberOf(group.GroupDN) { extUser.OrgRoles[group.OrgId] = group.OrgRole + extUser.IsGrafanaAdmin = group.IsGrafanaAdmin } } @@ -189,17 +201,18 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo } // add/update user in grafana - userQuery := &m.UpsertUserCommand{ + upsertUserCmd := &m.UpsertUserCommand{ ReqContext: ctx, ExternalUser: extUser, SignupAllowed: setting.LdapAllowSignup, } - err := bus.Dispatch(userQuery) + + err := bus.Dispatch(upsertUserCmd) if err != nil { return nil, err } - return userQuery.Result, nil + return upsertUserCmd.Result, nil } func (a *ldapAuther) serverBind() error { diff --git a/pkg/login/ldap_settings.go b/pkg/login/ldap_settings.go index 497d8725e29..7ebfbc79ba8 100644 --- a/pkg/login/ldap_settings.go +++ b/pkg/login/ldap_settings.go @@ -21,6 +21,8 @@ type LdapServerConf struct { StartTLS bool `toml:"start_tls"` SkipVerifySSL bool `toml:"ssl_skip_verify"` RootCACert string `toml:"root_ca_cert"` + ClientCert string `toml:"client_cert"` + ClientKey string `toml:"client_key"` BindDN string `toml:"bind_dn"` BindPassword string `toml:"bind_password"` Attr LdapAttributeMap `toml:"attributes"` @@ -44,9 +46,10 @@ type LdapAttributeMap struct { } type LdapGroupToOrgRole struct { - GroupDN string `toml:"group_dn"` - OrgId int64 `toml:"org_id"` - OrgRole m.RoleType `toml:"org_role"` + GroupDN string `toml:"group_dn"` + OrgId int64 `toml:"org_id"` + IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatability) + OrgRole m.RoleType `toml:"org_role"` } var LdapCfg LdapConfig diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go index 34932926406..1cf98bd1e14 100644 --- a/pkg/login/ldap_test.go +++ b/pkg/login/ldap_test.go @@ -1,6 +1,7 @@ package login import ( + "context" "crypto/tls" "testing" @@ -14,6 +15,14 @@ func TestLdapAuther(t *testing.T) { Convey("When translating ldap user to grafana user", t, func() { + var user1 = &m.User{} + + bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpsertUserCommand) error { + cmd.Result = user1 + cmd.Result.Login = "torkelo" + return nil + }) + Convey("Given no ldap group map match", func() { ldapAuther := NewLdapAuthenticator(&LdapServerConf{ LdapGroups: []*LdapGroupToOrgRole{{}}, @@ -23,8 +32,6 @@ func TestLdapAuther(t *testing.T) { So(err, ShouldEqual, ErrInvalidCredentials) }) - var user1 = &m.User{} - ldapAutherScenario("Given wildcard group match", func(sc *scenarioContext) { ldapAuther := NewLdapAuthenticator(&LdapServerConf{ LdapGroups: []*LdapGroupToOrgRole{ @@ -91,12 +98,15 @@ func TestLdapAuther(t *testing.T) { So(result.Login, ShouldEqual, "torkelo") }) + Convey("Should set isGrafanaAdmin to false by default", func() { + So(result.IsAdmin, ShouldBeFalse) + }) + }) }) Convey("When syncing ldap groups to grafana org roles", t, func() { - ldapAutherScenario("given no current user orgs", func(sc *scenarioContext) { ldapAuther := NewLdapAuthenticator(&LdapServerConf{ LdapGroups: []*LdapGroupToOrgRole{ @@ -217,8 +227,32 @@ func TestLdapAuther(t *testing.T) { So(sc.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN) So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1) }) + + Convey("Should not update permissions unless specified", func() { + So(err, ShouldBeNil) + So(sc.updateUserPermissionsCmd, ShouldBeNil) + }) }) + ldapAutherScenario("given ldap groups with grafana_admin=true", func(sc *scenarioContext) { + trueVal := true + + ldapAuther := NewLdapAuthenticator(&LdapServerConf{ + LdapGroups: []*LdapGroupToOrgRole{ + {GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin", IsGrafanaAdmin: &trueVal}, + }, + }) + + sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) + _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + MemberOf: []string{"cn=admins"}, + }) + + Convey("Should create user with admin set to true", func() { + So(err, ShouldBeNil) + So(sc.updateUserPermissionsCmd.IsGrafanaAdmin, ShouldBeTrue) + }) + }) }) Convey("When calling SyncUser", t, func() { @@ -322,6 +356,15 @@ func ldapAutherScenario(desc string, fn scenarioFunc) { bus.AddHandler("test", UpsertUser) + bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.SyncTeamsCommand) error { + return nil + }) + + bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpdateUserPermissionsCommand) error { + sc.updateUserPermissionsCmd = cmd + return nil + }) + bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error { sc.getUserByAuthInfoQuery = cmd sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login} @@ -369,14 +412,15 @@ func ldapAutherScenario(desc string, fn scenarioFunc) { } type scenarioContext struct { - getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery - getUserOrgListQuery *m.GetUserOrgListQuery - createUserCmd *m.CreateUserCommand - addOrgUserCmd *m.AddOrgUserCommand - updateOrgUserCmd *m.UpdateOrgUserCommand - removeOrgUserCmd *m.RemoveOrgUserCommand - updateUserCmd *m.UpdateUserCommand - setUsingOrgCmd *m.SetUsingOrgCommand + getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery + getUserOrgListQuery *m.GetUserOrgListQuery + createUserCmd *m.CreateUserCommand + addOrgUserCmd *m.AddOrgUserCommand + updateOrgUserCmd *m.UpdateOrgUserCommand + removeOrgUserCmd *m.RemoveOrgUserCommand + updateUserCmd *m.UpdateUserCommand + setUsingOrgCmd *m.SetUsingOrgCommand + updateUserPermissionsCmd *m.UpdateUserPermissionsCommand } func (sc *scenarioContext) userQueryReturns(user *m.User) { diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 3d3cfc2e1b6..a8d9f7308fa 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -44,6 +44,7 @@ var ( M_Alerting_Notification_Sent *prometheus.CounterVec M_Aws_CloudWatch_GetMetricStatistics prometheus.Counter M_Aws_CloudWatch_ListMetrics prometheus.Counter + M_Aws_CloudWatch_GetMetricData prometheus.Counter M_DB_DataSource_QueryById prometheus.Counter // Timers @@ -218,6 +219,12 @@ func init() { Namespace: exporterName, }) + M_Aws_CloudWatch_GetMetricData = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "aws_cloudwatch_get_metric_data_total", + Help: "counter for getting metric data time series from aws", + Namespace: exporterName, + }) + M_DB_DataSource_QueryById = prometheus.NewCounter(prometheus.CounterOpts{ Name: "db_datasource_query_by_id_total", Help: "counter for getting datasource by id", @@ -307,6 +314,7 @@ func initMetricVars() { M_Alerting_Notification_Sent, M_Aws_CloudWatch_GetMetricStatistics, M_Aws_CloudWatch_ListMetrics, + M_Aws_CloudWatch_GetMetricData, M_DB_DataSource_QueryById, M_Alerting_Active_Alerts, M_StatTotal_Dashboards, @@ -334,6 +342,14 @@ func updateTotalStats() { var usageStatsURL = "https://stats.grafana.org/grafana-usage-report" +func getEdition() string { + if setting.IsEnterprise { + return "enterprise" + } else { + return "oss" + } +} + func sendUsageStats() { if !setting.ReportingEnabled { return @@ -349,6 +365,7 @@ func sendUsageStats() { "metrics": metrics, "os": runtime.GOOS, "arch": runtime.GOARCH, + "edition": getEdition(), } statsQuery := models.GetSystemStatsQuery{} diff --git a/pkg/models/models.go b/pkg/models/models.go index c2560021ee1..ba894ae591f 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -8,4 +8,5 @@ const ( TWITTER GENERIC GRAFANA_COM + GITLAB ) diff --git a/pkg/models/playlist.go b/pkg/models/playlist.go index 5c49bb9256c..c52da202293 100644 --- a/pkg/models/playlist.go +++ b/pkg/models/playlist.go @@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard type UpdatePlaylistCommand struct { OrgId int64 `json:"-"` - Id int64 `json:"id" binding:"Required"` + Id int64 `json:"id"` Name string `json:"name" binding:"Required"` Interval string `json:"interval"` Items []PlaylistItemDTO `json:"items"` diff --git a/pkg/models/team.go b/pkg/models/team.go index 9c679a13394..61285db3a5f 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -49,13 +49,13 @@ type DeleteTeamCommand struct { type GetTeamByIdQuery struct { OrgId int64 Id int64 - Result *Team + Result *TeamDTO } type GetTeamsByUserQuery struct { OrgId int64 - UserId int64 `json:"userId"` - Result []*Team `json:"teams"` + UserId int64 `json:"userId"` + Result []*TeamDTO `json:"teams"` } type SearchTeamsQuery struct { @@ -68,7 +68,7 @@ type SearchTeamsQuery struct { Result SearchTeamQueryResult } -type SearchTeamDto struct { +type TeamDTO struct { Id int64 `json:"id"` OrgId int64 `json:"orgId"` Name string `json:"name"` @@ -78,8 +78,8 @@ type SearchTeamDto struct { } type SearchTeamQueryResult struct { - TotalCount int64 `json:"totalCount"` - Teams []*SearchTeamDto `json:"teams"` - Page int `json:"page"` - PerPage int `json:"perPage"` + TotalCount int64 `json:"totalCount"` + Teams []*TeamDTO `json:"teams"` + Page int `json:"page"` + PerPage int `json:"perPage"` } diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 19cf657292d..9434dad8ecd 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -42,6 +42,7 @@ type RemoveTeamMemberCommand struct { type GetTeamMembersQuery struct { OrgId int64 TeamId int64 + UserId int64 Result []*TeamMemberDTO } diff --git a/pkg/models/user_auth.go b/pkg/models/user_auth.go index 0ecd144d52c..28189005737 100644 --- a/pkg/models/user_auth.go +++ b/pkg/models/user_auth.go @@ -13,13 +13,15 @@ type UserAuth struct { } type ExternalUserInfo struct { - AuthModule string - AuthId string - UserId int64 - Email string - Login string - Name string - OrgRoles map[int64]RoleType + AuthModule string + AuthId string + UserId int64 + Email string + Login string + Name string + Groups []string + OrgRoles map[int64]RoleType + IsGrafanaAdmin *bool // This is a pointer to know if we should sync this or not (nil = ignore sync) } // --------------------- @@ -70,3 +72,8 @@ type GetAuthInfoQuery struct { Result *UserAuth } + +type SyncTeamsCommand struct { + ExternalUser *ExternalUserInfo + User *User +} diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index 2fec6acbf54..ff44805e35f 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -17,11 +17,14 @@ import ( plugin "github.com/hashicorp/go-plugin" ) +// DataSourcePlugin contains all metadata about a datasource plugin type DataSourcePlugin struct { FrontendPluginBase Annotations bool `json:"annotations"` Metrics bool `json:"metrics"` Alerting bool `json:"alerting"` + Explore bool `json:"explore"` + Logs bool `json:"logs"` QueryOptions map[string]bool `json:"queryOptions,omitempty"` BuiltIn bool `json:"builtIn,omitempty"` Mixed bool `json:"mixed,omitempty"` diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 6bbc302c593..87fca27f6c1 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -4,6 +4,8 @@ import ( "context" "reflect" "sort" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) type Descriptor struct { @@ -57,13 +59,21 @@ type CanBeDisabled interface { // BackgroundService should be implemented for services that have // long running tasks in the background. type BackgroundService interface { - // Run starts the background process of the service after `Init` have been called // on all services. The `context.Context` passed into the function should be used // to subscribe to ctx.Done() so the service can be notified when Grafana shuts down. Run(ctx context.Context) error } +// DatabaseMigrator allows the caller to add migrations to +// the migrator passed as argument +type DatabaseMigrator interface { + + // AddMigrations allows the service to add migrations to + // the database migrator. + AddMigration(mg *migrator.Migrator) +} + // IsDisabled takes an service and return true if its disabled func IsDisabled(srv Service) bool { canBeDisabled, ok := srv.(CanBeDisabled) diff --git a/pkg/services/alerting/conditions/reducer.go b/pkg/services/alerting/conditions/reducer.go index 0a61c13fa12..1e8ae792746 100644 --- a/pkg/services/alerting/conditions/reducer.go +++ b/pkg/services/alerting/conditions/reducer.go @@ -108,9 +108,9 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float { break } } - // get other points + // get the oldest point points = points[0:i] - for i := len(points) - 1; i >= 0; i-- { + for i := 0; i < len(points); i++ { if points[i][0].Valid { allNull = false value = first - points[i][0].Float64 @@ -131,9 +131,9 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float { break } } - // get other points + // get the oldest point points = points[0:i] - for i := len(points) - 1; i >= 0; i-- { + for i := 0; i < len(points); i++ { if points[i][0].Valid { allNull = false val := (first - points[i][0].Float64) / points[i][0].Float64 * 100 diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index 866b574f59f..9d4e1462690 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -110,16 +110,35 @@ func TestSimpleReducer(t *testing.T) { So(reducer.Reduce(series).Float64, ShouldEqual, float64(3)) }) - Convey("diff", func() { + Convey("diff one point", func() { + result := testReducer("diff", 30) + So(result, ShouldEqual, float64(0)) + }) + + Convey("diff two points", func() { result := testReducer("diff", 30, 40) So(result, ShouldEqual, float64(10)) }) - Convey("percent_diff", func() { + Convey("diff three points", func() { + result := testReducer("diff", 30, 40, 40) + So(result, ShouldEqual, float64(10)) + }) + + Convey("percent_diff one point", func() { + result := testReducer("percent_diff", 40) + So(result, ShouldEqual, float64(0)) + }) + + Convey("percent_diff two points", func() { result := testReducer("percent_diff", 30, 40) So(result, ShouldEqual, float64(33.33333333333333)) }) + Convey("percent_diff three points", func() { + result := testReducer("percent_diff", 30, 40, 40) + So(result, ShouldEqual, float64(33.33333333333333)) + }) }) } diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 861e9b9cbfc..c7212e48174 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -50,7 +50,7 @@ func TestAlertRuleExtraction(t *testing.T) { So(err, ShouldBeNil) Convey("Extractor should not modify the original json", func() { - dashJson, err := simplejson.NewJson([]byte(json)) + dashJson, err := simplejson.NewJson(json) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) @@ -79,7 +79,7 @@ func TestAlertRuleExtraction(t *testing.T) { Convey("Parsing and validating dashboard containing graphite alerts", func() { - dashJson, err := simplejson.NewJson([]byte(json)) + dashJson, err := simplejson.NewJson(json) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) @@ -143,7 +143,7 @@ func TestAlertRuleExtraction(t *testing.T) { panelWithoutId, err := ioutil.ReadFile("./test-data/panels-missing-id.json") So(err, ShouldBeNil) - dashJson, err := simplejson.NewJson([]byte(panelWithoutId)) + dashJson, err := simplejson.NewJson(panelWithoutId) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) extractor := NewDashAlertExtractor(dash, 1) @@ -159,7 +159,7 @@ func TestAlertRuleExtraction(t *testing.T) { panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json") So(err, ShouldBeNil) - dashJson, err := simplejson.NewJson([]byte(panelWithIdZero)) + dashJson, err := simplejson.NewJson(panelWithIdZero) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) extractor := NewDashAlertExtractor(dash, 1) diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 41c4e79445c..b458656404a 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/imguploader" @@ -106,7 +105,7 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) { renderOpts := rendering.Opts{ Width: 1000, Height: 500, - Timeout: time.Second * 30, + Timeout: alertTimeout / 2, OrgId: context.Rule.OrgId, OrgRole: m.ROLE_ADMIN, } diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index 93c9a0accc0..374b49ea957 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -58,7 +58,7 @@ func init() { data-placement="right"> - Provide a bot token to use the Slack file.upload API (starts with "xoxb") + Provide a bot token to use the Slack file.upload API (starts with "xoxb"). Specify #channel-name or @username in Recipient for this to work `, diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index cfd8f5c3a6e..7506338c5f0 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -30,7 +30,7 @@ type dashboardGuardianImpl struct { dashId int64 orgId int64 acl []*m.DashboardAclInfoDTO - groups []*m.Team + teams []*m.TeamDTO log log.Logger } @@ -186,15 +186,15 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) { return g.acl, nil } -func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) { - if g.groups != nil { - return g.groups, nil +func (g *dashboardGuardianImpl) getTeams() ([]*m.TeamDTO, error) { + if g.teams != nil { + return g.teams, nil } query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId} err := bus.Dispatch(&query) - g.groups = query.Result + g.teams = query.Result return query.Result, err } diff --git a/pkg/services/guardian/guardian_util_test.go b/pkg/services/guardian/guardian_util_test.go index 3d839e71b74..d85548ecb8c 100644 --- a/pkg/services/guardian/guardian_util_test.go +++ b/pkg/services/guardian/guardian_util_test.go @@ -19,7 +19,7 @@ type scenarioContext struct { givenUser *m.SignedInUser givenDashboardID int64 givenPermissions []*m.DashboardAclInfoDTO - givenTeams []*m.Team + givenTeams []*m.TeamDTO updatePermissions []*m.DashboardAcl expectedFlags permissionFlags callerFile string @@ -84,11 +84,11 @@ func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, per return nil }) - teams := []*m.Team{} + teams := []*m.TeamDTO{} for _, p := range permissions { if p.TeamId > 0 { - teams = append(teams, &m.Team{Id: p.TeamId}) + teams = append(teams, &m.TeamDTO{Id: p.TeamId}) } } diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index fcefa91243d..769fdd06fd0 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -98,8 +98,6 @@ func (ns *NotificationService) Run(ctx context.Context) error { return ctx.Err() } } - - return nil } func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error { diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 4b8931f0ed3..b2930c2b679 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -83,7 +83,7 @@ func (cr *configReader) parseDatasourceConfig(path string, file os.FileInfo) (*D } func validateDefaultUniqueness(datasources []*DatasourcesAsConfig) error { - defaultCount := 0 + defaultCount := map[int64]int{} for i := range datasources { if datasources[i].Datasources == nil { continue @@ -95,8 +95,8 @@ func validateDefaultUniqueness(datasources []*DatasourcesAsConfig) error { } if ds.IsDefault { - defaultCount++ - if defaultCount > 1 { + defaultCount[ds.OrgId] = defaultCount[ds.OrgId] + 1 + if defaultCount[ds.OrgId] > 1 { return ErrInvalidConfigToManyDefault } } diff --git a/pkg/services/provisioning/datasources/config_reader_test.go b/pkg/services/provisioning/datasources/config_reader_test.go index 2e407dbe4de..07c8d68e75c 100644 --- a/pkg/services/provisioning/datasources/config_reader_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -19,6 +19,7 @@ var ( allProperties = "testdata/all-properties" versionZero = "testdata/version-0" brokenYaml = "testdata/broken-yaml" + multipleOrgsWithDefault = "testdata/multiple-org-default" fakeRepo *fakeRepository ) @@ -73,6 +74,19 @@ func TestDatasourceAsConfig(t *testing.T) { }) }) + Convey("Multiple datasources in different organizations with isDefault in each organization", func() { + dc := newDatasourceProvisioner(logger) + err := dc.applyChanges(multipleOrgsWithDefault) + Convey("should not raise error", func() { + So(err, ShouldBeNil) + So(len(fakeRepo.inserted), ShouldEqual, 4) + So(fakeRepo.inserted[0].IsDefault, ShouldBeTrue) + So(fakeRepo.inserted[0].OrgId, ShouldEqual, 1) + So(fakeRepo.inserted[2].IsDefault, ShouldBeTrue) + So(fakeRepo.inserted[2].OrgId, ShouldEqual, 2) + }) + }) + Convey("Two configured datasource and purge others ", func() { Convey("two other datasources in database", func() { fakeRepo.loadAll = []*models.DataSource{ diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 1fa0a3b3173..de6c876baad 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -11,7 +11,7 @@ import ( ) var ( - ErrInvalidConfigToManyDefault = errors.New("datasource.yaml config is invalid. Only one datasource can be marked as default") + ErrInvalidConfigToManyDefault = errors.New("datasource.yaml config is invalid. Only one datasource per organization can be marked as default") ) func Provision(configDirectory string) error { diff --git a/pkg/services/provisioning/datasources/testdata/multiple-org-default/config.yaml b/pkg/services/provisioning/datasources/testdata/multiple-org-default/config.yaml new file mode 100644 index 00000000000..f185abb6f53 --- /dev/null +++ b/pkg/services/provisioning/datasources/testdata/multiple-org-default/config.yaml @@ -0,0 +1,25 @@ +apiVersion: 1 + +datasources: + - orgId: 1 + name: prometheus + type: prometheus + isDefault: True + access: proxy + url: http://prometheus.example.com:9090 + - name: Graphite + type: graphite + access: proxy + url: http://localhost:8080 + - orgId: 2 + name: prometheus + type: prometheus + isDefault: True + access: proxy + url: http://prometheus.example.com:9090 + - orgId: 2 + name: Graphite + type: graphite + access: proxy + url: http://localhost:8080 + diff --git a/pkg/services/rendering/phantomjs.go b/pkg/services/rendering/phantomjs.go index 8e06b5fed9d..87ccaf6b5d2 100644 --- a/pkg/services/rendering/phantomjs.go +++ b/pkg/services/rendering/phantomjs.go @@ -58,7 +58,9 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) ( cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", opts.Encoding)}, cmdArgs...) } - commandCtx, _ := context.WithTimeout(ctx, opts.Timeout+time.Second*2) + commandCtx, cancel := context.WithTimeout(ctx, opts.Timeout+time.Second*2) + defer cancel() + cmd := exec.CommandContext(commandCtx, binPath, cmdArgs...) cmd.Stderr = cmd.Stdout diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 531a70b2101..af911dc22e6 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { alert.name, alert.state, alert.new_state_date, + alert.eval_data, alert.eval_date, alert.execution_error, dashboard.uid as dashboard_uid, diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index 79fa99864e7..d97deb45f0e 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -13,7 +13,7 @@ func mockTimeNow() { var timeSeed int64 timeNow = func() time.Time { fakeNow := time.Unix(timeSeed, 0) - timeSeed += 1 + timeSeed++ return fakeNow } } @@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) { InitTestDB(t) testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert") - + evalData, _ := simplejson.NewJson([]byte(`{"test": "test"}`)) items := []*m.Alert{ { PanelId: 1, @@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) { Message: "Alerting message", Settings: simplejson.New(), Frequency: 1, + EvalData: evalData, }, } @@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) { alert := alertQuery.Result[0] So(err2, ShouldBeNil) + So(alert.Id, ShouldBeGreaterThan, 0) + So(alert.DashboardId, ShouldEqual, testDash.Id) + So(alert.PanelId, ShouldEqual, 1) So(alert.Name, ShouldEqual, "Alerting title") So(alert.State, ShouldEqual, "pending") + So(alert.NewStateDate, ShouldNotBeNil) + So(alert.EvalData, ShouldNotBeNil) + So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test") + So(alert.EvalDate, ShouldNotBeNil) + So(alert.ExecutionError, ShouldEqual, "") + So(alert.DashboardUid, ShouldNotBeNil) + So(alert.DashboardSlug, ShouldEqual, "dashboard-with-alerts") }) Convey("Viewer cannot read alerts", func() { diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index e4aecf0391d..8ff78c4a0ff 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -181,7 +181,7 @@ func TestDashboardDataAccess(t *testing.T) { So(err, ShouldBeNil) So(query.Result.FolderId, ShouldEqual, 0) So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy) - So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second)) + So(query.Result.Created, ShouldHappenWithin, 3*time.Second, savedDash.Created) So(query.Result.UpdatedBy, ShouldEqual, 100) So(query.Result.Updated.IsZero(), ShouldBeFalse) }) @@ -387,6 +387,7 @@ func insertTestDashboardForPlugin(title string, orgId int64, folderId int64, isF func createUser(name string, role string, isAdmin bool) m.User { setting.AutoAssignOrg = true + setting.AutoAssignOrgId = 1 setting.AutoAssignOrgRole = role currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin} diff --git a/pkg/services/sqlstore/migrations/team_mig.go b/pkg/services/sqlstore/migrations/team_mig.go index eb0641fbc32..9800d27f8ab 100644 --- a/pkg/services/sqlstore/migrations/team_mig.go +++ b/pkg/services/sqlstore/migrations/team_mig.go @@ -50,4 +50,5 @@ func addTeamMigrations(mg *Migrator) { mg.AddMigration("Add column email to team table", NewAddColumnMigration(teamV1, &Column{ Name: "email", Type: DB_NVarchar, Nullable: true, Length: 190, })) + } diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 521a2a11c05..af8500707d5 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -17,6 +17,7 @@ func TestAccountDataAccess(t *testing.T) { Convey("Given single org mode", func() { setting.AutoAssignOrg = true + setting.AutoAssignOrgId = 1 setting.AutoAssignOrgRole = "Viewer" Convey("Users should be added to default organization", func() { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index b0edc1676e0..13d706b6198 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -132,6 +132,13 @@ func (ss *SqlStore) Init() error { migrator := migrator.NewMigrator(x) migrations.AddMigrations(migrator) + for _, descriptor := range registry.GetServices() { + sc, ok := descriptor.Instance.(registry.DatabaseMigrator) + if ok { + sc.AddMigration(migrator) + } + } + if err := migrator.Start(); err != nil { return fmt.Errorf("Migration failed err: %v", err) } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 7d53d114235..72955df9a6a 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -22,6 +22,16 @@ func init() { bus.AddHandler("sql", GetTeamMembers) } +func getTeamSelectSqlBase() string { + return `SELECT + team.id as id, + team.org_id, + team.name as name, + team.email as email, + (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count + FROM team as team ` +} + func CreateTeam(cmd *m.CreateTeamCommand) error { return inTransaction(func(sess *DBSession) error { @@ -130,21 +140,15 @@ func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession func SearchTeams(query *m.SearchTeamsQuery) error { query.Result = m.SearchTeamQueryResult{ - Teams: make([]*m.SearchTeamDto, 0), + Teams: make([]*m.TeamDTO, 0), } queryWithWildcards := "%" + query.Query + "%" var sql bytes.Buffer params := make([]interface{}, 0) - sql.WriteString(`select - team.id as id, - team.org_id, - team.name as name, - team.email as email, - (select count(*) from team_member where team_member.team_id = team.id) as member_count - from team as team - where team.org_id = ?`) + sql.WriteString(getTeamSelectSqlBase()) + sql.WriteString(` WHERE team.org_id = ?`) params = append(params, query.OrgId) @@ -186,8 +190,14 @@ func SearchTeams(query *m.SearchTeamsQuery) error { } func GetTeamById(query *m.GetTeamByIdQuery) error { - var team m.Team - exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team) + var sql bytes.Buffer + + sql.WriteString(getTeamSelectSqlBase()) + sql.WriteString(` WHERE team.org_id = ? and team.id = ?`) + + var team m.TeamDTO + exists, err := x.Sql(sql.String(), query.OrgId, query.Id).Get(&team) + if err != nil { return err } @@ -202,13 +212,15 @@ func GetTeamById(query *m.GetTeamByIdQuery) error { // GetTeamsByUser is used by the Guardian when checking a users' permissions func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { - query.Result = make([]*m.Team, 0) + query.Result = make([]*m.TeamDTO, 0) - sess := x.Table("team") - sess.Join("INNER", "team_member", "team.id=team_member.team_id") - sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId) + var sql bytes.Buffer - err := sess.Find(&query.Result) + sql.WriteString(getTeamSelectSqlBase()) + sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`) + sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`) + + err := x.Sql(sql.String(), query.OrgId, query.UserId).Find(&query.Result) return err } @@ -268,7 +280,15 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error { query.Result = make([]*m.TeamMemberDTO, 0) sess := x.Table("team_member") sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user"))) - sess.Where("team_member.org_id=? and team_member.team_id=?", query.OrgId, query.TeamId) + if query.OrgId != 0 { + sess.Where("team_member.org_id=?", query.OrgId) + } + if query.TeamId != 0 { + sess.Where("team_member.team_id=?", query.TeamId) + } + if query.UserId != 0 { + sess.Where("team_member.user_id=?", query.UserId) + } sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login") sess.Asc("user.login", "user.email") diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 5e9a085b26d..0ec1a947870 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -42,16 +42,23 @@ func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error var org m.Org if setting.AutoAssignOrg { - // right now auto assign to org with id 1 - has, err := sess.Where("id=?", 1).Get(&org) + has, err := sess.Where("id=?", setting.AutoAssignOrgId).Get(&org) if err != nil { return 0, err } if has { return org.Id, nil + } else { + if setting.AutoAssignOrgId == 1 { + org.Name = "Main Org." + org.Id = int64(setting.AutoAssignOrgId) + } else { + sqlog.Info("Could not create user: organization id %v does not exist", + setting.AutoAssignOrgId) + return 0, fmt.Errorf("Could not create user: organization id %v does not exist", + setting.AutoAssignOrgId) + } } - org.Name = "Main Org." - org.Id = 1 } else { org.Name = cmd.OrgName if len(org.Name) == 0 { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index e71a3619aa5..eb61568261d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -18,9 +18,10 @@ import ( "github.com/go-macaron/session" + "time" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" - "time" ) type Scheme string @@ -49,7 +50,7 @@ var ( BuildVersion string BuildCommit string BuildStamp int64 - Enterprise bool + IsEnterprise bool ApplicationName string // Paths @@ -99,6 +100,7 @@ var ( AllowUserSignUp bool AllowUserOrgCreate bool AutoAssignOrg bool + AutoAssignOrgId int AutoAssignOrgRole string VerifyEmailEnabled bool LoginHint string @@ -517,7 +519,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { Raw = cfg.Raw ApplicationName = "Grafana" - if Enterprise { + if IsEnterprise { ApplicationName += " Enterprise" } @@ -591,6 +593,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { AllowUserSignUp = users.Key("allow_sign_up").MustBool(true) AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true) AutoAssignOrg = users.Key("auto_assign_org").MustBool(true) + AutoAssignOrgId = users.Key("auto_assign_org_id").MustInt(1) AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"}) VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) LoginHint = users.Key("login_hint").String() diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index 815c684cf03..b07f112b8d3 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -213,6 +213,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi userInfo := &BasicUserInfo{ Name: data.Login, Login: data.Login, + Id: fmt.Sprintf("%d", data.Id), Email: data.Email, } diff --git a/pkg/social/gitlab_oauth.go b/pkg/social/gitlab_oauth.go new file mode 100644 index 00000000000..21463dabf8f --- /dev/null +++ b/pkg/social/gitlab_oauth.go @@ -0,0 +1,132 @@ +package social + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + + "github.com/grafana/grafana/pkg/models" + + "golang.org/x/oauth2" +) + +type SocialGitlab struct { + *SocialBase + allowedDomains []string + allowedGroups []string + apiUrl string + allowSignup bool +} + +var ( + ErrMissingGroupMembership = &Error{"User not a member of one of the required groups"} +) + +func (s *SocialGitlab) Type() int { + return int(models.GITLAB) +} + +func (s *SocialGitlab) IsEmailAllowed(email string) bool { + return isEmailAllowed(email, s.allowedDomains) +} + +func (s *SocialGitlab) IsSignupAllowed() bool { + return s.allowSignup +} + +func (s *SocialGitlab) IsGroupMember(client *http.Client) bool { + if len(s.allowedGroups) == 0 { + return true + } + + for groups, url := s.GetGroups(client, s.apiUrl+"/groups"); groups != nil; groups, url = s.GetGroups(client, url) { + for _, allowedGroup := range s.allowedGroups { + for _, group := range groups { + if group == allowedGroup { + return true + } + } + } + } + + return false +} + +func (s *SocialGitlab) GetGroups(client *http.Client, url string) ([]string, string) { + type Group struct { + FullPath string `json:"full_path"` + } + + var ( + groups []Group + next string + ) + + if url == "" { + return nil, next + } + + response, err := HttpGet(client, url) + if err != nil { + s.log.Error("Error getting groups from GitLab API", "err", err) + return nil, next + } + + if err := json.Unmarshal(response.Body, &groups); err != nil { + s.log.Error("Error parsing JSON from GitLab API", "err", err) + return nil, next + } + + fullPaths := make([]string, len(groups)) + for i, group := range groups { + fullPaths[i] = group.FullPath + } + + if link, ok := response.Headers["Link"]; ok { + pattern := regexp.MustCompile(`<([^>]+)>; rel="next"`) + if matches := pattern.FindStringSubmatch(link[0]); matches != nil { + next = matches[1] + } + } + + return fullPaths, next +} + +func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { + + var data struct { + Id int + Username string + Email string + Name string + State string + } + + response, err := HttpGet(client, s.apiUrl+"/user") + if err != nil { + return nil, fmt.Errorf("Error getting user info: %s", err) + } + + err = json.Unmarshal(response.Body, &data) + if err != nil { + return nil, fmt.Errorf("Error getting user info: %s", err) + } + + if data.State != "active" { + return nil, fmt.Errorf("User %s is inactive", data.Username) + } + + userInfo := &BasicUserInfo{ + Id: fmt.Sprintf("%d", data.Id), + Name: data.Name, + Login: data.Username, + Email: data.Email, + } + + if !s.IsGroupMember(client) { + return nil, ErrMissingGroupMembership + } + + return userInfo, nil +} diff --git a/pkg/social/social.go b/pkg/social/social.go index adbe5a912d9..2be71514629 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -55,7 +55,7 @@ func NewOAuthService() { setting.OAuthService = &setting.OAuther{} setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) - allOauthes := []string{"github", "google", "generic_oauth", "grafananet", "grafana_com"} + allOauthes := []string{"github", "gitlab", "google", "generic_oauth", "grafananet", "grafana_com"} for _, name := range allOauthes { sec := setting.Raw.Section("auth." + name) @@ -115,6 +115,20 @@ func NewOAuthService() { } } + // GitLab. + if name == "gitlab" { + SocialMap["gitlab"] = &SocialGitlab{ + SocialBase: &SocialBase{ + Config: &config, + log: logger, + }, + allowedDomains: info.AllowedDomains, + apiUrl: info.ApiUrl, + allowSignup: info.AllowSignup, + allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), + } + } + // Google. if name == "google" { SocialMap["google"] = &SocialGoogle{ diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 8af97575ae9..92352a51315 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -14,8 +14,10 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb" + "golang.org/x/sync/errgroup" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" @@ -88,48 +90,80 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo Results: make(map[string]*tsdb.QueryResult), } - errCh := make(chan error, 1) - resCh := make(chan *tsdb.QueryResult, 1) + eg, ectx := errgroup.WithContext(ctx) - currentlyExecuting := 0 + getMetricDataQueries := make(map[string]map[string]*CloudWatchQuery) for i, model := range queryContext.Queries { queryType := model.Model.Get("type").MustString() if queryType != "timeSeriesQuery" && queryType != "" { continue } - currentlyExecuting++ - go func(refId string, index int) { - queryRes, err := e.executeQuery(ctx, queryContext.Queries[index].Model, queryContext) - currentlyExecuting-- - if err != nil { - errCh <- err - } else { - queryRes.RefId = refId - resCh <- queryRes + + RefId := queryContext.Queries[i].RefId + query, err := parseQuery(queryContext.Queries[i].Model) + if err != nil { + result.Results[RefId] = &tsdb.QueryResult{ + Error: err, } - }(model.RefId, i) + return result, nil + } + query.RefId = RefId + + if query.Id != "" { + if _, ok := getMetricDataQueries[query.Region]; !ok { + getMetricDataQueries[query.Region] = make(map[string]*CloudWatchQuery) + } + getMetricDataQueries[query.Region][query.Id] = query + continue + } + + if query.Id == "" && query.Expression != "" { + result.Results[query.RefId] = &tsdb.QueryResult{ + Error: fmt.Errorf("Invalid query: id should be set if using expression"), + } + return result, nil + } + + eg.Go(func() error { + queryRes, err := e.executeQuery(ectx, query, queryContext) + if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { + return err + } + result.Results[queryRes.RefId] = queryRes + if err != nil { + result.Results[queryRes.RefId].Error = err + } + return nil + }) } - for currentlyExecuting != 0 { - select { - case res := <-resCh: - result.Results[res.RefId] = res - case err := <-errCh: - return result, err - case <-ctx.Done(): - return result, ctx.Err() + if len(getMetricDataQueries) > 0 { + for region, getMetricDataQuery := range getMetricDataQueries { + q := getMetricDataQuery + eg.Go(func() error { + queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext) + if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { + return err + } + for _, queryRes := range queryResponses { + result.Results[queryRes.RefId] = queryRes + if err != nil { + result.Results[queryRes.RefId].Error = err + } + } + return nil + }) } } + if err := eg.Wait(); err != nil { + return nil, err + } + return result, nil } -func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { - query, err := parseQuery(parameters) - if err != nil { - return nil, err - } - +func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { client, err := e.getClient(query.Region) if err != nil { return nil, err @@ -201,6 +235,139 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl return queryRes, nil } +func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, region string, queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) ([]*tsdb.QueryResult, error) { + queryResponses := make([]*tsdb.QueryResult, 0) + + // validate query + for _, query := range queries { + if !(len(query.Statistics) == 1 && len(query.ExtendedStatistics) == 0) && + !(len(query.Statistics) == 0 && len(query.ExtendedStatistics) == 1) { + return queryResponses, errors.New("Statistics count should be 1") + } + } + + client, err := e.getClient(region) + if err != nil { + return queryResponses, err + } + + startTime, err := queryContext.TimeRange.ParseFrom() + if err != nil { + return queryResponses, err + } + + endTime, err := queryContext.TimeRange.ParseTo() + if err != nil { + return queryResponses, err + } + + params := &cloudwatch.GetMetricDataInput{ + StartTime: aws.Time(startTime), + EndTime: aws.Time(endTime), + ScanBy: aws.String("TimestampAscending"), + } + for _, query := range queries { + // 1 minutes resolutin metrics is stored for 15 days, 15 * 24 * 60 = 21600 + if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) { + return nil, errors.New("too long query period") + } + + mdq := &cloudwatch.MetricDataQuery{ + Id: aws.String(query.Id), + ReturnData: aws.Bool(query.ReturnData), + } + if query.Expression != "" { + mdq.Expression = aws.String(query.Expression) + } else { + mdq.MetricStat = &cloudwatch.MetricStat{ + Metric: &cloudwatch.Metric{ + Namespace: aws.String(query.Namespace), + MetricName: aws.String(query.MetricName), + }, + Period: aws.Int64(int64(query.Period)), + } + for _, d := range query.Dimensions { + mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions, + &cloudwatch.Dimension{ + Name: d.Name, + Value: d.Value, + }) + } + if len(query.Statistics) == 1 { + mdq.MetricStat.Stat = query.Statistics[0] + } else { + mdq.MetricStat.Stat = query.ExtendedStatistics[0] + } + } + params.MetricDataQueries = append(params.MetricDataQueries, mdq) + } + + nextToken := "" + mdr := make(map[string]*cloudwatch.MetricDataResult) + for { + if nextToken != "" { + params.NextToken = aws.String(nextToken) + } + resp, err := client.GetMetricDataWithContext(ctx, params) + if err != nil { + return queryResponses, err + } + metrics.M_Aws_CloudWatch_GetMetricData.Add(float64(len(params.MetricDataQueries))) + + for _, r := range resp.MetricDataResults { + if _, ok := mdr[*r.Id]; !ok { + mdr[*r.Id] = r + } else { + mdr[*r.Id].Timestamps = append(mdr[*r.Id].Timestamps, r.Timestamps...) + mdr[*r.Id].Values = append(mdr[*r.Id].Values, r.Values...) + } + } + + if resp.NextToken == nil || *resp.NextToken == "" { + break + } + nextToken = *resp.NextToken + } + + for i, r := range mdr { + if *r.StatusCode != "Complete" { + return queryResponses, fmt.Errorf("Part of query is failed: %s", *r.StatusCode) + } + + queryRes := tsdb.NewQueryResult() + queryRes.RefId = queries[i].RefId + query := queries[*r.Id] + + series := tsdb.TimeSeries{ + Tags: map[string]string{}, + Points: make([]tsdb.TimePoint, 0), + } + for _, d := range query.Dimensions { + series.Tags[*d.Name] = *d.Value + } + s := "" + if len(query.Statistics) == 1 { + s = *query.Statistics[0] + } else { + s = *query.ExtendedStatistics[0] + } + series.Name = formatAlias(query, s, series.Tags) + + for j, t := range r.Timestamps { + expectedTimestamp := r.Timestamps[j].Add(time.Duration(query.Period) * time.Second) + if j > 0 && expectedTimestamp.Before(*t) { + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000))) + } + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*r.Values[j]), float64((*t).Unix())*1000)) + } + + queryRes.Series = append(queryRes.Series, &series) + queryResponses = append(queryResponses, queryRes) + } + + return queryResponses, nil +} + func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) { var result []*cloudwatch.Dimension @@ -257,6 +424,9 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { return nil, err } + id := model.Get("id").MustString("") + expression := model.Get("expression").MustString("") + dimensions, err := parseDimensions(model) if err != nil { return nil, err @@ -295,6 +465,7 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { alias = "{{metric}}_{{stat}}" } + returnData := model.Get("returnData").MustBool(false) highResolution := model.Get("highResolution").MustBool(false) return &CloudWatchQuery{ @@ -306,11 +477,18 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { ExtendedStatistics: aws.StringSlice(extendedStatistics), Period: period, Alias: alias, + Id: id, + Expression: expression, + ReturnData: returnData, HighResolution: highResolution, }, nil } func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string { + if len(query.Id) > 0 && len(query.Expression) > 0 { + return query.Id + } + data := map[string]string{} data["region"] = query.Region data["namespace"] = query.Namespace @@ -338,6 +516,7 @@ func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]stri func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) { queryRes := tsdb.NewQueryResult() + queryRes.RefId = query.RefId var value float64 for _, s := range append(query.Statistics, query.ExtendedStatistics...) { series := tsdb.TimeSeries{ diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 136ee241c2e..ef1b53eaf1b 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -46,6 +46,7 @@ func init() { "AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"}, "AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"}, "AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"}, + "AWS/DX": {"ConnectionState", "ConnectionBpsEgress", "ConnectionBpsIngress", "ConnectionPpsEgress", "ConnectionPpsIngress", "ConnectionCRCErrorCount", "ConnectionLightLevelTx", "ConnectionLightLevelRx"}, "AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"}, "AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"}, "AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"}, @@ -86,13 +87,14 @@ func init() { "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "WriteProvisionedThroughputExceeded", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords"}, "AWS/KinesisAnalytics": {"Bytes", "MillisBehindLatest", "Records", "Success"}, "AWS/Lambda": {"Invocations", "Errors", "Duration", "Throttles", "IteratorAge"}, + "AWS/AppSync": {"Latency", "4XXError", "5XXError"}, "AWS/Logs": {"IncomingBytes", "IncomingLogEvents", "ForwardedBytes", "ForwardedLogEvents", "DeliveryErrors", "DeliveryThrottling"}, "AWS/ML": {"PredictCount", "PredictFailureCount"}, "AWS/NATGateway": {"PacketsOutToDestination", "PacketsOutToSource", "PacketsInFromSource", "PacketsInFromDestination", "BytesOutToDestination", "BytesOutToSource", "BytesInFromSource", "BytesInFromDestination", "ErrorPortAllocation", "ActiveConnectionCount", "ConnectionAttemptCount", "ConnectionEstablishedCount", "IdleTimeoutCount", "PacketsDropCount"}, "AWS/NetworkELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "UnHealthyHostCount"}, "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, - "AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "ReadIOPS", "ReadLatency", "ReadThroughput", "WriteIOPS", "WriteLatency", "WriteThroughput"}, - "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMQueueLength", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"}, "AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"}, "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"}, @@ -118,6 +120,7 @@ func init() { "AWS/CloudFront": {"DistributionId", "Region"}, "AWS/CloudSearch": {}, "AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"}, + "AWS/DX": {"ConnectionId"}, "AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"}, "AWS/EBS": {"VolumeId"}, "AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"}, @@ -135,12 +138,13 @@ func init() { "AWS/Kinesis": {"StreamName", "ShardId"}, "AWS/KinesisAnalytics": {"Flow", "Id", "Application"}, "AWS/Lambda": {"FunctionName", "Resource", "Version", "Alias"}, + "AWS/AppSync": {"GraphQLAPIId"}, "AWS/Logs": {"LogGroupName", "DestinationType", "FilterName"}, "AWS/ML": {"MLModelId", "RequestMode"}, "AWS/NATGateway": {"NatGatewayId"}, "AWS/NetworkELB": {"LoadBalancer", "TargetGroup", "AvailabilityZone"}, "AWS/OpsWorks": {"StackId", "LayerId", "InstanceId"}, - "AWS/Redshift": {"NodeID", "ClusterIdentifier"}, + "AWS/Redshift": {"NodeID", "ClusterIdentifier", "latency", "service class", "wmlid"}, "AWS/RDS": {"DBInstanceIdentifier", "DBClusterIdentifier", "DbClusterIdentifier", "DatabaseClass", "EngineName", "Role"}, "AWS/Route53": {"HealthCheckId", "Region"}, "AWS/S3": {"BucketName", "StorageType", "FilterId"}, diff --git a/pkg/tsdb/cloudwatch/types.go b/pkg/tsdb/cloudwatch/types.go index 0737b64686d..1225fb9b31b 100644 --- a/pkg/tsdb/cloudwatch/types.go +++ b/pkg/tsdb/cloudwatch/types.go @@ -5,6 +5,7 @@ import ( ) type CloudWatchQuery struct { + RefId string Region string Namespace string MetricName string @@ -13,5 +14,8 @@ type CloudWatchQuery struct { ExtendedStatistics []*string Period int Alias string + Id string + Expression string + ReturnData bool HighResolution bool } diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index efc3ed1bda2..dff626a79eb 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -218,7 +218,7 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch elapsed := time.Now().Sub(start) clientLog.Debug("Decoded multisearch json response", "took", elapsed) - msr.status = res.StatusCode + msr.Status = res.StatusCode return &msr, nil } diff --git a/pkg/tsdb/elasticsearch/client/index_pattern.go b/pkg/tsdb/elasticsearch/client/index_pattern.go index 8391e902ea4..952b5c4f806 100644 --- a/pkg/tsdb/elasticsearch/client/index_pattern.go +++ b/pkg/tsdb/elasticsearch/client/index_pattern.go @@ -248,13 +248,28 @@ var datePatternReplacements = map[string]string{ func formatDate(t time.Time, pattern string) string { var datePattern string - parts := strings.Split(strings.TrimLeft(pattern, "["), "]") - base := parts[0] - if len(parts) == 2 { - datePattern = parts[1] - } else { - datePattern = base - base = "" + base := "" + ltr := false + + if strings.HasPrefix(pattern, "[") { + parts := strings.Split(strings.TrimLeft(pattern, "["), "]") + base = parts[0] + if len(parts) == 2 { + datePattern = parts[1] + } else { + datePattern = base + base = "" + } + ltr = true + } else if strings.HasSuffix(pattern, "]") { + parts := strings.Split(strings.TrimRight(pattern, "]"), "[") + datePattern = parts[0] + if len(parts) == 2 { + base = parts[1] + } else { + base = "" + } + ltr = false } formatted := t.Format(patternToLayout(datePattern)) @@ -293,7 +308,11 @@ func formatDate(t time.Time, pattern string) string { formatted = strings.Replace(formatted, "", fmt.Sprintf("%d", t.Hour()), -1) } - return base + formatted + if ltr { + return base + formatted + } + + return formatted + base } func patternToLayout(pattern string) string { diff --git a/pkg/tsdb/elasticsearch/client/index_pattern_test.go b/pkg/tsdb/elasticsearch/client/index_pattern_test.go index 3bd823d8c87..ca20b39d532 100644 --- a/pkg/tsdb/elasticsearch/client/index_pattern_test.go +++ b/pkg/tsdb/elasticsearch/client/index_pattern_test.go @@ -28,29 +28,54 @@ func TestIndexPattern(t *testing.T) { to := fmt.Sprintf("%d", time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond)) indexPatternScenario(intervalHourly, "[data-]YYYY.MM.DD.HH", tsdb.NewTimeRange(from, to), func(indices []string) { - //So(indices, ShouldHaveLength, 1) + So(indices, ShouldHaveLength, 1) So(indices[0], ShouldEqual, "data-2018.05.15.17") }) + indexPatternScenario(intervalHourly, "YYYY.MM.DD.HH[-data]", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "2018.05.15.17-data") + }) + indexPatternScenario(intervalDaily, "[data-]YYYY.MM.DD", tsdb.NewTimeRange(from, to), func(indices []string) { So(indices, ShouldHaveLength, 1) So(indices[0], ShouldEqual, "data-2018.05.15") }) + indexPatternScenario(intervalDaily, "YYYY.MM.DD[-data]", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "2018.05.15-data") + }) + indexPatternScenario(intervalWeekly, "[data-]GGGG.WW", tsdb.NewTimeRange(from, to), func(indices []string) { So(indices, ShouldHaveLength, 1) So(indices[0], ShouldEqual, "data-2018.20") }) + indexPatternScenario(intervalWeekly, "GGGG.WW[-data]", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "2018.20-data") + }) + indexPatternScenario(intervalMonthly, "[data-]YYYY.MM", tsdb.NewTimeRange(from, to), func(indices []string) { So(indices, ShouldHaveLength, 1) So(indices[0], ShouldEqual, "data-2018.05") }) + indexPatternScenario(intervalMonthly, "YYYY.MM[-data]", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "2018.05-data") + }) + indexPatternScenario(intervalYearly, "[data-]YYYY", tsdb.NewTimeRange(from, to), func(indices []string) { So(indices, ShouldHaveLength, 1) So(indices[0], ShouldEqual, "data-2018") }) + + indexPatternScenario(intervalYearly, "YYYY[-data]", tsdb.NewTimeRange(from, to), func(indices []string) { + So(indices, ShouldHaveLength, 1) + So(indices[0], ShouldEqual, "2018-data") + }) }) Convey("Hourly interval", t, func() { diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go index a5810a9b109..a0d257d01a6 100644 --- a/pkg/tsdb/elasticsearch/client/models.go +++ b/pkg/tsdb/elasticsearch/client/models.go @@ -74,7 +74,7 @@ type MultiSearchRequest struct { // MultiSearchResponse represents a multi search response type MultiSearchResponse struct { - status int `json:"status,omitempty"` + Status int `json:"status,omitempty"` Responses []*SearchResponse `json:"responses"` } diff --git a/pkg/tsdb/elasticsearch/client/search_request_test.go b/pkg/tsdb/elasticsearch/client/search_request_test.go index b026578d64f..862b8058cba 100644 --- a/pkg/tsdb/elasticsearch/client/search_request_test.go +++ b/pkg/tsdb/elasticsearch/client/search_request_test.go @@ -32,7 +32,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) So(json.Get("size").MustInt(500), ShouldEqual, 0) So(json.Get("sort").Interface(), ShouldBeNil) @@ -81,7 +81,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) So(json.Get("size").MustInt(0), ShouldEqual, 200) @@ -124,7 +124,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) scriptFields, err := json.Get("script_fields").Map() @@ -163,7 +163,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) So(json.Get("aggs").MustMap(), ShouldHaveLength, 2) @@ -200,7 +200,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) So(json.Get("aggs").MustMap(), ShouldHaveLength, 1) @@ -251,7 +251,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) topAggOne := json.GetPath("aggs", "1") @@ -300,7 +300,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) topAgg := json.GetPath("aggs", "1") @@ -364,7 +364,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) termsAgg := json.GetPath("aggs", "1") @@ -419,7 +419,7 @@ func TestSearchRequest(t *testing.T) { Convey("When marshal to JSON should generate correct json", func() { body, err := json.Marshal(sr) So(err, ShouldBeNil) - json, err := simplejson.NewJson([]byte(body)) + json, err := simplejson.NewJson(body) So(err, ShouldBeNil) scriptFields, err := json.Get("script_fields").Map() diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go index bb9489cd654..caba043e7b6 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/macros.go @@ -6,26 +6,24 @@ import ( "strings" "time" - "strconv" - "github.com/grafana/grafana/pkg/tsdb" ) const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` -type MsSqlMacroEngine struct { - TimeRange *tsdb.TimeRange - Query *tsdb.Query +type msSqlMacroEngine struct { + timeRange *tsdb.TimeRange + query *tsdb.Query } -func NewMssqlMacroEngine() tsdb.SqlMacroEngine { - return &MsSqlMacroEngine{} +func newMssqlMacroEngine() tsdb.SqlMacroEngine { + return &msSqlMacroEngine{} } -func (m *MsSqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { - m.TimeRange = timeRange - m.Query = query +func (m *msSqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + m.timeRange = timeRange + m.query = query rExp, _ := regexp.Compile(sExpr) var macroError error @@ -66,7 +64,7 @@ func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]str return result + str[lastIndex:] } -func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { +func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__time": if len(args) == 0 { @@ -82,11 +80,12 @@ func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, er if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= DATEADD(s, %d, '1970-01-01') AND %s <= DATEADD(s, %d, '1970-01-01')", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil + + return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeFrom": - return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", m.TimeRange.GetFromAsSecondsEpoch()), nil + return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil case "__timeTo": - return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) @@ -96,28 +95,48 @@ func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - m.Query.Model.Set("fill", true) - m.Query.Model.Set("fillInterval", interval.Seconds()) - if args[2] == "NULL" { - m.Query.Model.Set("fillNull", true) - } else { - floatVal, err := strconv.ParseFloat(args[2], 64) - if err != nil { - return "", fmt.Errorf("error parsing fill value %v", args[2]) - } - m.Query.Model.Set("fillValue", floatVal) + err := tsdb.SetupFillmode(m.query, interval, args[2]) + if err != nil { + return "", err } } - return fmt.Sprintf("CAST(ROUND(DATEDIFF(second, '1970-01-01', %s)/%.1f, 0) as bigint)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil + return fmt.Sprintf("FLOOR(DATEDIFF(second, '1970-01-01', %s)/%.0f)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil + case "__timeGroupAlias": + tg, err := m.evaluateMacro("__timeGroup", args) + if err == nil { + return tg + " AS [time]", err + } + return "", err case "__unixEpochFilter": if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil case "__unixEpochFrom": - return fmt.Sprintf("%d", m.TimeRange.GetFromAsSecondsEpoch()), nil + return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil case "__unixEpochTo": - return fmt.Sprintf("%d", m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil + case "__unixEpochGroup": + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) + } + interval, err := time.ParseDuration(strings.Trim(args[1], `'`)) + if err != nil { + return "", fmt.Errorf("error parsing interval %v", args[1]) + } + if len(args) == 3 { + err := tsdb.SetupFillmode(m.query, interval, args[2]) + if err != nil { + return "", err + } + } + return fmt.Sprintf("FLOOR(%s/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil + case "__unixEpochGroupAlias": + tg, err := m.evaluateMacro("__unixEpochGroup", args) + if err == nil { + return tg + " AS [time]", err + } + return "", err default: return "", fmt.Errorf("Unknown macro %v", name) } diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/macros_test.go index ae0d4f67d2b..8e0973b750c 100644 --- a/pkg/tsdb/mssql/macros_test.go +++ b/pkg/tsdb/mssql/macros_test.go @@ -14,7 +14,7 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { - engine := &MsSqlMacroEngine{} + engine := &msSqlMacroEngine{} query := &tsdb.Query{ Model: simplejson.New(), } @@ -49,33 +49,52 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) Convey("interpolate __timeGroup function", func() { sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')") + So(err, ShouldBeNil) - So(sql, ShouldEqual, "GROUP BY CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)*300") + So(sql, ShouldEqual, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300") + So(sql2, ShouldEqual, sql+" AS [time]") }) Convey("interpolate __timeGroup function with spaces around arguments", func() { sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')") + So(err, ShouldBeNil) - So(sql, ShouldEqual, "GROUP BY CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)*300") + So(sql, ShouldEqual, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300") + So(sql2, ShouldEqual, sql+" AS [time]") }) Convey("interpolate __timeGroup function with fill (value = NULL)", func() { _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', NULL)") fill := query.Model.Get("fill").MustBool() - fillNull := query.Model.Get("fillNull").MustBool() + fillMode := query.Model.Get("fillMode").MustString() fillInterval := query.Model.Get("fillInterval").MustInt() So(err, ShouldBeNil) So(fill, ShouldBeTrue) - So(fillNull, ShouldBeTrue) + So(fillMode, ShouldEqual, "null") + So(fillInterval, ShouldEqual, 5*time.Minute.Seconds()) + }) + + Convey("interpolate __timeGroup function with fill (value = previous)", func() { + _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', previous)") + + fill := query.Model.Get("fill").MustBool() + fillMode := query.Model.Get("fillMode").MustString() + fillInterval := query.Model.Get("fillInterval").MustInt() + + So(err, ShouldBeNil) + So(fill, ShouldBeTrue) + So(fillMode, ShouldEqual, "previous") So(fillInterval, ShouldEqual, 5*time.Minute.Seconds()) }) @@ -96,14 +115,14 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) Convey("interpolate __timeTo function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) }) Convey("interpolate __unixEpochFilter function", func() { @@ -126,6 +145,18 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) }) + + Convey("interpolate __unixEpochGroup function", func() { + + sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") + So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "SELECT FLOOR(time_column/300)*300") + So(sql2, ShouldEqual, sql+" AS [time]") + }) + }) Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { @@ -137,21 +168,21 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) Convey("interpolate __timeFrom function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) Convey("interpolate __timeTo function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) }) Convey("interpolate __unixEpochFilter function", func() { @@ -185,21 +216,21 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) Convey("interpolate __timeFrom function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) Convey("interpolate __timeTo function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) }) Convey("interpolate __unixEpochFilter function", func() { diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index eb71259b46b..72e57d03fa0 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -1,49 +1,40 @@ package mssql import ( - "container/list" - "context" "database/sql" "fmt" "strconv" "strings" - "math" - _ "github.com/denisenkom/go-mssqldb" "github.com/go-xorm/core" - "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" ) -type MssqlQueryEndpoint struct { - sqlEngine tsdb.SqlEngine - log log.Logger -} - func init() { - tsdb.RegisterTsdbQueryEndpoint("mssql", NewMssqlQueryEndpoint) + tsdb.RegisterTsdbQueryEndpoint("mssql", newMssqlQueryEndpoint) } -func NewMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - endpoint := &MssqlQueryEndpoint{ - log: log.New("tsdb.mssql"), - } - - endpoint.sqlEngine = &tsdb.DefaultSqlEngine{ - MacroEngine: NewMssqlMacroEngine(), - } +func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + logger := log.New("tsdb.mssql") cnnstr := generateConnectionString(datasource) - endpoint.log.Debug("getEngine", "connection", cnnstr) + logger.Debug("getEngine", "connection", cnnstr) - if err := endpoint.sqlEngine.InitEngine("mssql", datasource, cnnstr); err != nil { - return nil, err + config := tsdb.SqlQueryEndpointConfiguration{ + DriverName: "mssql", + ConnectionString: cnnstr, + Datasource: datasource, + MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, } - return endpoint, nil + rowTransformer := mssqlRowTransformer{ + log: logger, + } + + return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger) } func generateConnectionString(datasource *models.DataSource) string { @@ -70,71 +61,16 @@ func generateConnectionString(datasource *models.DataSource) string { ) } -// Query is the main function for the MssqlQueryEndpoint -func (e *MssqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { - return e.sqlEngine.Query(ctx, dsInfo, tsdbQuery, e.transformToTimeSeries, e.transformToTable) +type mssqlRowTransformer struct { + log log.Logger } -func (e MssqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { - columnNames, err := rows.Columns() - columnCount := len(columnNames) +func (t *mssqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) { + values := make([]interface{}, len(columnTypes)) + valuePtrs := make([]interface{}, len(columnTypes)) - if err != nil { - return err - } - - rowLimit := 1000000 - rowCount := 0 - timeIndex := -1 - - table := &tsdb.Table{ - Columns: make([]tsdb.TableColumn, columnCount), - Rows: make([]tsdb.RowValues, 0), - } - - for i, name := range columnNames { - table.Columns[i].Text = name - - // check if there is a column named time - switch name { - case "time": - timeIndex = i - } - } - - columnTypes, err := rows.ColumnTypes() - if err != nil { - return err - } - - for ; rows.Next(); rowCount++ { - if rowCount > rowLimit { - return fmt.Errorf("MsSQL query row limit exceeded, limit %d", rowLimit) - } - - values, err := e.getTypedRowData(columnTypes, rows) - if err != nil { - return err - } - - // converts column named time to unix timestamp in milliseconds - // to make native mssql datetime types and epoch dates work in - // annotation and table queries. - tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) - table.Rows = append(table.Rows, values) - } - - result.Tables = append(result.Tables, table) - result.Meta.Set("rowCount", rowCount) - return nil -} - -func (e MssqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) { - values := make([]interface{}, len(types)) - valuePtrs := make([]interface{}, len(types)) - - for i, stype := range types { - e.log.Debug("type", "type", stype) + for i, stype := range columnTypes { + t.log.Debug("type", "type", stype) valuePtrs[i] = &values[i] } @@ -144,17 +80,17 @@ func (e MssqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core. // convert types not handled by denisenkom/go-mssqldb // unhandled types are returned as []byte - for i := 0; i < len(types); i++ { + for i := 0; i < len(columnTypes); i++ { if value, ok := values[i].([]byte); ok { - switch types[i].DatabaseTypeName() { + switch columnTypes[i].DatabaseTypeName() { case "MONEY", "SMALLMONEY", "DECIMAL": if v, err := strconv.ParseFloat(string(value), 64); err == nil { values[i] = v } else { - e.log.Debug("Rows", "Error converting numeric to float", value) + t.log.Debug("Rows", "Error converting numeric to float", value) } default: - e.log.Debug("Rows", "Unknown database type", types[i].DatabaseTypeName(), "value", value) + t.log.Debug("Rows", "Unknown database type", columnTypes[i].DatabaseTypeName(), "value", value) values[i] = string(value) } } @@ -162,157 +98,3 @@ func (e MssqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core. return values, nil } - -func (e MssqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { - pointsBySeries := make(map[string]*tsdb.TimeSeries) - seriesByQueryOrder := list.New() - - columnNames, err := rows.Columns() - if err != nil { - return err - } - - columnTypes, err := rows.ColumnTypes() - if err != nil { - return err - } - - rowLimit := 1000000 - rowCount := 0 - timeIndex := -1 - metricIndex := -1 - - // check columns of resultset: a column named time is mandatory - // the first text column is treated as metric name unless a column named metric is present - for i, col := range columnNames { - switch col { - case "time": - timeIndex = i - case "metric": - metricIndex = i - default: - if metricIndex == -1 { - switch columnTypes[i].DatabaseTypeName() { - case "VARCHAR", "CHAR", "NVARCHAR", "NCHAR": - metricIndex = i - } - } - } - } - - if timeIndex == -1 { - return fmt.Errorf("Found no column named time") - } - - fillMissing := query.Model.Get("fill").MustBool(false) - var fillInterval float64 - fillValue := null.Float{} - if fillMissing { - fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 - if !query.Model.Get("fillNull").MustBool(false) { - fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() - fillValue.Valid = true - } - } - - for rows.Next() { - var timestamp float64 - var value null.Float - var metric string - - if rowCount > rowLimit { - return fmt.Errorf("MSSQL query row limit exceeded, limit %d", rowLimit) - } - - values, err := e.getTypedRowData(columnTypes, rows) - if err != nil { - return err - } - - // converts column named time to unix timestamp in milliseconds to make - // native mysql datetime types and epoch dates work in - // annotation and table queries. - tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) - - switch columnValue := values[timeIndex].(type) { - case int64: - timestamp = float64(columnValue) - case float64: - timestamp = columnValue - default: - return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) - } - - if metricIndex >= 0 { - if columnValue, ok := values[metricIndex].(string); ok { - metric = columnValue - } else { - return fmt.Errorf("Column metric must be of type CHAR, VARCHAR, NCHAR or NVARCHAR. metric column name: %s type: %s but datatype is %T", columnNames[metricIndex], columnTypes[metricIndex].DatabaseTypeName(), values[metricIndex]) - } - } - - for i, col := range columnNames { - if i == timeIndex || i == metricIndex { - continue - } - - if value, err = tsdb.ConvertSqlValueColumnToFloat(col, values[i]); err != nil { - return err - } - - if metricIndex == -1 { - metric = col - } - - series, exist := pointsBySeries[metric] - if !exist { - series = &tsdb.TimeSeries{Name: metric} - pointsBySeries[metric] = series - seriesByQueryOrder.PushBack(metric) - } - - if fillMissing { - var intervalStart float64 - if !exist { - intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) - } else { - intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval - } - - // align interval start - intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval - - for i := intervalStart; i < timestamp; i += fillInterval { - series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) - rowCount++ - } - } - - series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) - - e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) - } - } - - for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() { - key := elem.Value.(string) - result.Series = append(result.Series, pointsBySeries[key]) - - if fillMissing { - series := pointsBySeries[key] - // fill in values from last fetched value till interval end - intervalStart := series.Points[len(series.Points)-1][1].Float64 - intervalEnd := float64(tsdbQuery.TimeRange.MustGetTo().UnixNano() / 1e6) - - // align interval start - intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval - for i := intervalStart + fillInterval; i < intervalEnd; i += fillInterval { - series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) - rowCount++ - } - } - } - - result.Meta.Set("rowCount", rowCount) - return nil -} diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index e62d30a6325..30d1da3bda1 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -8,8 +8,9 @@ import ( "time" "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" @@ -19,8 +20,9 @@ import ( // The tests require a MSSQL db named grafanatest and a user/password grafana/Password! // Use the docker/blocks/mssql_tests/docker-compose.yaml to spin up a // preconfigured MSSQL server suitable for running these tests. -// There is also a dashboard.json in same directory that you can import to Grafana -// once you've created a datasource for the test server/database. +// There is also a datasource and dashboard provisioned by devenv scripts that you can +// use to verify that the generated data are vizualized as expected, see +// devenv/README.md for setup instructions. // If needed, change the variable below to the IP address of the database. var serverIP = "localhost" @@ -28,19 +30,25 @@ func TestMSSQL(t *testing.T) { SkipConvey("MSSQL", t, func() { x := InitMSSQLTestDB(t) - endpoint := &MssqlQueryEndpoint{ - sqlEngine: &tsdb.DefaultSqlEngine{ - MacroEngine: NewMssqlMacroEngine(), - XormEngine: x, - }, - log: log.New("tsdb.mssql"), + origXormEngine := tsdb.NewXormEngine + tsdb.NewXormEngine = func(d, c string) (*xorm.Engine, error) { + return x, nil } - sess := x.NewSession() - defer sess.Close() + endpoint, err := newMssqlQueryEndpoint(&models.DataSource{ + JsonData: simplejson.New(), + SecureJsonData: securejsondata.SecureJsonData{}, + }) + So(err, ShouldBeNil) + sess := x.NewSession() fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + Reset(func() { + sess.Close() + tsdb.NewXormEngine = origXormEngine + }) + Convey("Given a table with different native data types", func() { sql := ` IF OBJECT_ID('dbo.[mssql_types]', 'U') IS NOT NULL @@ -210,11 +218,12 @@ func TestMSSQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) points := queryResult.Series[0].Points - So(len(points), ShouldEqual, 6) + // without fill this should result in 4 buckets + So(len(points), ShouldEqual, 4) dt := fromStart - for i := 0; i < 3; i++ { + for i := 0; i < 2; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 15) @@ -222,9 +231,9 @@ func TestMSSQL(t *testing.T) { dt = dt.Add(5 * time.Minute) } - // adjust for 5 minute gap - dt = dt.Add(5 * time.Minute) - for i := 3; i < 6; i++ { + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 2; i < 4; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 20) @@ -260,7 +269,7 @@ func TestMSSQL(t *testing.T) { dt := fromStart - for i := 0; i < 3; i++ { + for i := 0; i < 2; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 15) @@ -268,17 +277,22 @@ func TestMSSQL(t *testing.T) { dt = dt.Add(5 * time.Minute) } + // check for NULL values inserted by fill + So(points[2][0].Valid, ShouldBeFalse) So(points[3][0].Valid, ShouldBeFalse) - // adjust for 5 minute gap - dt = dt.Add(5 * time.Minute) - for i := 4; i < 7; i++ { + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 4; i < 6; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 20) So(aTime, ShouldEqual, dt) dt = dt.Add(5 * time.Minute) } + + So(points[6][0].Valid, ShouldBeFalse) + }) Convey("When doing a metric query using timeGroup with float fill enabled", func() { @@ -525,7 +539,7 @@ func TestMSSQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 1) - So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3) }) Convey("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable) should return metric with time in milliseconds", func() { @@ -547,7 +561,7 @@ func TestMSSQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 1) - So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3) }) Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { @@ -596,6 +610,31 @@ func TestMSSQL(t *testing.T) { So(queryResult.Series[1].Name, ShouldEqual, "valueTwo") }) + Convey("When doing a metric query with metric column and multiple value columns", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeEpoch(time), measurement, valueOne, valueTwo FROM metric_values ORDER BY 1", + "format": "time_series", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + So(len(queryResult.Series), ShouldEqual, 4) + So(queryResult.Series[0].Name, ShouldEqual, "Metric A valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "Metric A valueTwo") + So(queryResult.Series[2].Name, ShouldEqual, "Metric B valueOne") + So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo") + }) + Convey("Given a stored procedure that takes @from and @to in epoch time", func() { sql := ` IF object_id('sp_test_epoch') IS NOT NULL @@ -621,21 +660,9 @@ func TestMSSQL(t *testing.T) { SELECT CAST(ROUND(DATEDIFF(second, '1970-01-01', time)/CAST(@dInterval as float), 0) as bigint)*@dInterval as time, - measurement + ' - value one' as metric, - avg(valueOne) as value - FROM - metric_values - WHERE - time BETWEEN DATEADD(s, @from, '1970-01-01') AND DATEADD(s, @to, '1970-01-01') AND - (@metric = 'ALL' OR measurement = @metric) - GROUP BY - CAST(ROUND(DATEDIFF(second, '1970-01-01', time)/CAST(@dInterval as float), 0) as bigint)*@dInterval, - measurement - UNION ALL - SELECT - CAST(ROUND(DATEDIFF(second, '1970-01-01', time)/CAST(@dInterval as float), 0) as bigint)*@dInterval as time, - measurement + ' - value two' as metric, - avg(valueTwo) as value + measurement as metric, + avg(valueOne) as valueOne, + avg(valueTwo) as valueTwo FROM metric_values WHERE @@ -678,10 +705,10 @@ func TestMSSQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 4) - So(queryResult.Series[0].Name, ShouldEqual, "Metric A - value one") - So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one") - So(queryResult.Series[2].Name, ShouldEqual, "Metric A - value two") - So(queryResult.Series[3].Name, ShouldEqual, "Metric B - value two") + So(queryResult.Series[0].Name, ShouldEqual, "Metric A valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "Metric A valueTwo") + So(queryResult.Series[2].Name, ShouldEqual, "Metric B valueOne") + So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo") }) }) @@ -710,21 +737,9 @@ func TestMSSQL(t *testing.T) { SELECT CAST(ROUND(DATEDIFF(second, '1970-01-01', time)/CAST(@dInterval as float), 0) as bigint)*@dInterval as time, - measurement + ' - value one' as metric, - avg(valueOne) as value - FROM - metric_values - WHERE - time BETWEEN @from AND @to AND - (@metric = 'ALL' OR measurement = @metric) - GROUP BY - CAST(ROUND(DATEDIFF(second, '1970-01-01', time)/CAST(@dInterval as float), 0) as bigint)*@dInterval, - measurement - UNION ALL - SELECT - CAST(ROUND(DATEDIFF(second, '1970-01-01', time)/CAST(@dInterval as float), 0) as bigint)*@dInterval as time, - measurement + ' - value two' as metric, - avg(valueTwo) as value + measurement as metric, + avg(valueOne) as valueOne, + avg(valueTwo) as valueTwo FROM metric_values WHERE @@ -767,10 +782,10 @@ func TestMSSQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 4) - So(queryResult.Series[0].Name, ShouldEqual, "Metric A - value one") - So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one") - So(queryResult.Series[2].Name, ShouldEqual, "Metric A - value two") - So(queryResult.Series[3].Name, ShouldEqual, "Metric B - value two") + So(queryResult.Series[0].Name, ShouldEqual, "Metric A valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "Metric A valueTwo") + So(queryResult.Series[2].Name, ShouldEqual, "Metric B valueOne") + So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo") }) }) }) @@ -924,7 +939,7 @@ func TestMSSQL(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) }) Convey("When doing an annotation query with a time column in epoch second format (int) should return ms", func() { @@ -954,7 +969,7 @@ func TestMSSQL(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) }) Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() { diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index fadcbe4edbc..0dabdd7c283 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -3,7 +3,6 @@ package mysql import ( "fmt" "regexp" - "strconv" "strings" "time" @@ -14,18 +13,18 @@ import ( const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` -type MySqlMacroEngine struct { - TimeRange *tsdb.TimeRange - Query *tsdb.Query +type mySqlMacroEngine struct { + timeRange *tsdb.TimeRange + query *tsdb.Query } -func NewMysqlMacroEngine() tsdb.SqlMacroEngine { - return &MySqlMacroEngine{} +func newMysqlMacroEngine() tsdb.SqlMacroEngine { + return &mySqlMacroEngine{} } -func (m *MySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { - m.TimeRange = timeRange - m.Query = query +func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + m.timeRange = timeRange + m.query = query rExp, _ := regexp.Compile(sExpr) var macroError error @@ -66,7 +65,7 @@ func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]str return result + str[lastIndex:] } -func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { +func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__timeEpoch", "__time": if len(args) == 0 { @@ -77,11 +76,12 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= FROM_UNIXTIME(%d) AND %s <= FROM_UNIXTIME(%d)", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil + + return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeFrom": - return fmt.Sprintf("FROM_UNIXTIME(%d)", m.TimeRange.GetFromAsSecondsEpoch()), nil + return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil case "__timeTo": - return fmt.Sprintf("FROM_UNIXTIME(%d)", m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) @@ -91,28 +91,48 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - m.Query.Model.Set("fill", true) - m.Query.Model.Set("fillInterval", interval.Seconds()) - if args[2] == "NULL" { - m.Query.Model.Set("fillNull", true) - } else { - floatVal, err := strconv.ParseFloat(args[2], 64) - if err != nil { - return "", fmt.Errorf("error parsing fill value %v", args[2]) - } - m.Query.Model.Set("fillValue", floatVal) + err := tsdb.SetupFillmode(m.query, interval, args[2]) + if err != nil { + return "", err } } - return fmt.Sprintf("cast(cast(UNIX_TIMESTAMP(%s)/(%.0f) as signed)*%.0f as signed)", args[0], interval.Seconds(), interval.Seconds()), nil + return fmt.Sprintf("UNIX_TIMESTAMP(%s) DIV %.0f * %.0f", args[0], interval.Seconds(), interval.Seconds()), nil + case "__timeGroupAlias": + tg, err := m.evaluateMacro("__timeGroup", args) + if err == nil { + return tg + " AS \"time\"", err + } + return "", err case "__unixEpochFilter": if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil case "__unixEpochFrom": - return fmt.Sprintf("%d", m.TimeRange.GetFromAsSecondsEpoch()), nil + return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil case "__unixEpochTo": - return fmt.Sprintf("%d", m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil + case "__unixEpochGroup": + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) + } + interval, err := time.ParseDuration(strings.Trim(args[1], `'`)) + if err != nil { + return "", fmt.Errorf("error parsing interval %v", args[1]) + } + if len(args) == 3 { + err := tsdb.SetupFillmode(m.query, interval, args[2]) + if err != nil { + return "", err + } + } + return fmt.Sprintf("%s DIV %v * %v", args[0], interval.Seconds(), interval.Seconds()), nil + case "__unixEpochGroupAlias": + tg, err := m.evaluateMacro("__unixEpochGroup", args) + if err == nil { + return tg + " AS \"time\"", err + } + return "", err default: return "", fmt.Errorf("Unknown macro %v", name) } diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 66ec143eac8..fe153ca3e2d 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -12,7 +12,7 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { - engine := &MySqlMacroEngine{} + engine := &mySqlMacroEngine{} query := &tsdb.Query{} Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { @@ -38,37 +38,43 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')") + So(err, ShouldBeNil) - So(sql, ShouldEqual, "GROUP BY cast(cast(UNIX_TIMESTAMP(time_column)/(300) as signed)*300 as signed)") + So(sql, ShouldEqual, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300") + So(sql2, ShouldEqual, sql+" AS \"time\"") }) Convey("interpolate __timeGroup function with spaces around arguments", func() { sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')") + So(err, ShouldBeNil) - So(sql, ShouldEqual, "GROUP BY cast(cast(UNIX_TIMESTAMP(time_column)/(300) as signed)*300 as signed)") + So(sql, ShouldEqual, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300") + So(sql2, ShouldEqual, sql+" AS \"time\"") }) Convey("interpolate __timeFilter function", func() { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) Convey("interpolate __timeFrom function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) Convey("interpolate __timeTo function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) }) Convey("interpolate __unixEpochFilter function", func() { @@ -91,6 +97,18 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) }) + + Convey("interpolate __unixEpochGroup function", func() { + + sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") + So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "SELECT time_column DIV 300 * 300") + So(sql2, ShouldEqual, sql+" AS \"time\"") + }) + }) Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { @@ -102,21 +120,21 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) Convey("interpolate __timeFrom function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) Convey("interpolate __timeTo function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) }) Convey("interpolate __unixEpochFilter function", func() { @@ -150,21 +168,21 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) Convey("interpolate __timeFrom function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) Convey("interpolate __timeTo function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) }) Convey("interpolate __unixEpochFilter function", func() { diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 7eceaffdb09..645f6b49bbb 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -1,39 +1,24 @@ package mysql import ( - "container/list" - "context" "database/sql" "fmt" - "math" "reflect" "strconv" "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" - "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" ) -type MysqlQueryEndpoint struct { - sqlEngine tsdb.SqlEngine - log log.Logger -} - func init() { - tsdb.RegisterTsdbQueryEndpoint("mysql", NewMysqlQueryEndpoint) + tsdb.RegisterTsdbQueryEndpoint("mysql", newMysqlQueryEndpoint) } -func NewMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - endpoint := &MysqlQueryEndpoint{ - log: log.New("tsdb.mysql"), - } - - endpoint.sqlEngine = &tsdb.DefaultSqlEngine{ - MacroEngine: NewMysqlMacroEngine(), - } +func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + logger := log.New("tsdb.mysql") cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true", datasource.User, @@ -42,85 +27,35 @@ func NewMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin datasource.Url, datasource.Database, ) - endpoint.log.Debug("getEngine", "connection", cnnstr) + logger.Debug("getEngine", "connection", cnnstr) - if err := endpoint.sqlEngine.InitEngine("mysql", datasource, cnnstr); err != nil { - return nil, err + config := tsdb.SqlQueryEndpointConfiguration{ + DriverName: "mysql", + ConnectionString: cnnstr, + Datasource: datasource, + TimeColumnNames: []string{"time", "time_sec"}, + MetricColumnTypes: []string{"CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"}, } - return endpoint, nil + rowTransformer := mysqlRowTransformer{ + log: logger, + } + + return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMysqlMacroEngine(), logger) } -// Query is the main function for the MysqlExecutor -func (e *MysqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { - return e.sqlEngine.Query(ctx, dsInfo, tsdbQuery, e.transformToTimeSeries, e.transformToTable) +type mysqlRowTransformer struct { + log log.Logger } -func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { - columnNames, err := rows.Columns() - columnCount := len(columnNames) - - if err != nil { - return err - } - - table := &tsdb.Table{ - Columns: make([]tsdb.TableColumn, columnCount), - Rows: make([]tsdb.RowValues, 0), - } - - for i, name := range columnNames { - table.Columns[i].Text = name - } - - rowLimit := 1000000 - rowCount := 0 - timeIndex := -1 - - // check if there is a column named time - for i, col := range columnNames { - switch col { - case "time", "time_sec": - timeIndex = i - } - } - - for ; rows.Next(); rowCount++ { - if rowCount > rowLimit { - return fmt.Errorf("MySQL query row limit exceeded, limit %d", rowLimit) - } - - values, err := e.getTypedRowData(rows) - if err != nil { - return err - } - - // converts column named time to unix timestamp in milliseconds to make - // native mysql datetime types and epoch dates work in - // annotation and table queries. - tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) - - table.Rows = append(table.Rows, values) - } - - result.Tables = append(result.Tables, table) - result.Meta.Set("rowCount", rowCount) - return nil -} - -func (e MysqlQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, error) { - types, err := rows.ColumnTypes() - if err != nil { - return nil, err - } - - values := make([]interface{}, len(types)) +func (t *mysqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) { + values := make([]interface{}, len(columnTypes)) for i := range values { - scanType := types[i].ScanType() + scanType := columnTypes[i].ScanType() values[i] = reflect.New(scanType).Interface() - if types[i].DatabaseTypeName() == "BIT" { + if columnTypes[i].DatabaseTypeName() == "BIT" { values[i] = new([]byte) } } @@ -129,7 +64,7 @@ func (e MysqlQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, er return nil, err } - for i := 0; i < len(types); i++ { + for i := 0; i < len(columnTypes); i++ { typeName := reflect.ValueOf(values[i]).Type().String() switch typeName { @@ -158,7 +93,7 @@ func (e MysqlQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, er } } - if types[i].DatabaseTypeName() == "DECIMAL" { + if columnTypes[i].DatabaseTypeName() == "DECIMAL" { f, err := strconv.ParseFloat(values[i].(string), 64) if err == nil { @@ -171,159 +106,3 @@ func (e MysqlQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, er return values, nil } - -func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { - pointsBySeries := make(map[string]*tsdb.TimeSeries) - seriesByQueryOrder := list.New() - - columnNames, err := rows.Columns() - if err != nil { - return err - } - - columnTypes, err := rows.ColumnTypes() - if err != nil { - return err - } - - rowLimit := 1000000 - rowCount := 0 - timeIndex := -1 - metricIndex := -1 - - // check columns of resultset: a column named time is mandatory - // the first text column is treated as metric name unless a column named metric is present - for i, col := range columnNames { - switch col { - case "time", "time_sec": - timeIndex = i - case "metric": - metricIndex = i - default: - if metricIndex == -1 { - switch columnTypes[i].DatabaseTypeName() { - case "CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT": - metricIndex = i - } - } - } - } - - if timeIndex == -1 { - return fmt.Errorf("Found no column named time or time_sec") - } - - fillMissing := query.Model.Get("fill").MustBool(false) - var fillInterval float64 - fillValue := null.Float{} - if fillMissing { - fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 - if !query.Model.Get("fillNull").MustBool(false) { - fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() - fillValue.Valid = true - } - } - - for rows.Next() { - var timestamp float64 - var value null.Float - var metric string - - if rowCount > rowLimit { - return fmt.Errorf("PostgreSQL query row limit exceeded, limit %d", rowLimit) - } - - values, err := e.getTypedRowData(rows) - if err != nil { - return err - } - - // converts column named time to unix timestamp in milliseconds to make - // native mysql datetime types and epoch dates work in - // annotation and table queries. - tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) - - switch columnValue := values[timeIndex].(type) { - case int64: - timestamp = float64(columnValue) - case float64: - timestamp = columnValue - default: - return fmt.Errorf("Invalid type for column time/time_sec, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) - } - - if metricIndex >= 0 { - if columnValue, ok := values[metricIndex].(string); ok { - metric = columnValue - } else { - return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) - } - } - - for i, col := range columnNames { - if i == timeIndex || i == metricIndex { - continue - } - - if value, err = tsdb.ConvertSqlValueColumnToFloat(col, values[i]); err != nil { - return err - } - - if metricIndex == -1 { - metric = col - } - - series, exist := pointsBySeries[metric] - if !exist { - series = &tsdb.TimeSeries{Name: metric} - pointsBySeries[metric] = series - seriesByQueryOrder.PushBack(metric) - } - - if fillMissing { - var intervalStart float64 - if !exist { - intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) - } else { - intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval - } - - // align interval start - intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval - - for i := intervalStart; i < timestamp; i += fillInterval { - series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) - rowCount++ - } - } - - series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) - - e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) - rowCount++ - - } - } - - for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() { - key := elem.Value.(string) - result.Series = append(result.Series, pointsBySeries[key]) - - if fillMissing { - series := pointsBySeries[key] - // fill in values from last fetched value till interval end - intervalStart := series.Points[len(series.Points)-1][1].Float64 - intervalEnd := float64(tsdbQuery.TimeRange.MustGetTo().UnixNano() / 1e6) - - // align interval start - intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval - for i := intervalStart + fillInterval; i < intervalEnd; i += fillInterval { - series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) - rowCount++ - } - } - } - - result.Meta.Set("rowCount", rowCount) - return nil -} diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 5650de237c5..ca6df8e360e 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -8,8 +8,9 @@ import ( "time" "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" @@ -21,8 +22,9 @@ import ( // The tests require a MySQL db named grafana_ds_tests and a user/password grafana/password // Use the docker/blocks/mysql_tests/docker-compose.yaml to spin up a // preconfigured MySQL server suitable for running these tests. -// There is also a dashboard.json in same directory that you can import to Grafana -// once you've created a datasource for the test server/database. +// There is also a datasource and dashboard provisioned by devenv scripts that you can +// use to verify that the generated data are vizualized as expected, see +// devenv/README.md for setup instructions. func TestMySQL(t *testing.T) { // change to true to run the MySQL tests runMySqlTests := false @@ -35,19 +37,25 @@ func TestMySQL(t *testing.T) { Convey("MySQL", t, func() { x := InitMySQLTestDB(t) - endpoint := &MysqlQueryEndpoint{ - sqlEngine: &tsdb.DefaultSqlEngine{ - MacroEngine: NewMysqlMacroEngine(), - XormEngine: x, - }, - log: log.New("tsdb.mysql"), + origXormEngine := tsdb.NewXormEngine + tsdb.NewXormEngine = func(d, c string) (*xorm.Engine, error) { + return x, nil } - sess := x.NewSession() - defer sess.Close() + endpoint, err := newMysqlQueryEndpoint(&models.DataSource{ + JsonData: simplejson.New(), + SecureJsonData: securejsondata.SecureJsonData{}, + }) + So(err, ShouldBeNil) + sess := x.NewSession() fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC) + Reset(func() { + sess.Close() + tsdb.NewXormEngine = origXormEngine + }) + Convey("Given a table with different native data types", func() { if exists, err := sess.IsTableExist("mysql_types"); err != nil || exists { So(err, ShouldBeNil) @@ -132,8 +140,8 @@ func TestMySQL(t *testing.T) { So(column[7].(float64), ShouldEqual, 1.11) So(column[8].(float64), ShouldEqual, 2.22) So(*column[9].(*float32), ShouldEqual, 3.33) - So(column[10].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now()) - So(column[11].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now()) + So(column[10].(time.Time), ShouldHappenWithin, 10*time.Second, time.Now()) + So(column[11].(time.Time), ShouldHappenWithin, 10*time.Second, time.Now()) So(column[12].(string), ShouldEqual, "11:11:11") So(column[13].(int64), ShouldEqual, 2018) So(*column[14].(*[]byte), ShouldHaveSameTypeAs, []byte{1}) @@ -209,11 +217,12 @@ func TestMySQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) points := queryResult.Series[0].Points - So(len(points), ShouldEqual, 6) + // without fill this should result in 4 buckets + So(len(points), ShouldEqual, 4) dt := fromStart - for i := 0; i < 3; i++ { + for i := 0; i < 2; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 15) @@ -221,9 +230,9 @@ func TestMySQL(t *testing.T) { dt = dt.Add(5 * time.Minute) } - // adjust for 5 minute gap - dt = dt.Add(5 * time.Minute) - for i := 3; i < 6; i++ { + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 2; i < 4; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 20) @@ -259,7 +268,7 @@ func TestMySQL(t *testing.T) { dt := fromStart - for i := 0; i < 3; i++ { + for i := 0; i < 2; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 15) @@ -267,20 +276,26 @@ func TestMySQL(t *testing.T) { dt = dt.Add(5 * time.Minute) } + // check for NULL values inserted by fill + So(points[2][0].Valid, ShouldBeFalse) So(points[3][0].Valid, ShouldBeFalse) - // adjust for 5 minute gap - dt = dt.Add(5 * time.Minute) - for i := 4; i < 7; i++ { + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 4; i < 6; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 20) So(aTime, ShouldEqual, dt) dt = dt.Add(5 * time.Minute) } + + // check for NULL values inserted by fill + So(points[6][0].Valid, ShouldBeFalse) + }) - Convey("When doing a metric query using timeGroup with float fill enabled", func() { + Convey("When doing a metric query using timeGroup with value fill enabled", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -305,6 +320,35 @@ func TestMySQL(t *testing.T) { points := queryResult.Series[0].Points So(points[3][0].Float64, ShouldEqual, 1.5) }) + + Convey("When doing a metric query using timeGroup with previous fill enabled", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m', previous) as time_sec, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + points := queryResult.Series[0].Points + So(points[2][0].Float64, ShouldEqual, 15.0) + So(points[3][0].Float64, ShouldEqual, 15.0) + So(points[6][0].Float64, ShouldEqual, 20.0) + }) + }) Convey("Given a table with metrics having multiple values and measurements", func() { @@ -571,7 +615,7 @@ func TestMySQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 1) - So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3) }) Convey("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable) should return metric with time in milliseconds", func() { @@ -593,7 +637,7 @@ func TestMySQL(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 1) - So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3) }) Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { @@ -619,6 +663,31 @@ func TestMySQL(t *testing.T) { So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one") }) + Convey("When doing a metric query with metric column and multiple value columns", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT $__time(time), measurement as metric, valueOne, valueTwo FROM metric_values ORDER BY 1,2`, + "format": "time_series", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + So(len(queryResult.Series), ShouldEqual, 4) + So(queryResult.Series[0].Name, ShouldEqual, "Metric A valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "Metric A valueTwo") + So(queryResult.Series[2].Name, ShouldEqual, "Metric B valueOne") + So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo") + }) + Convey("When doing a metric query grouping by time should return correct series", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ @@ -810,7 +879,7 @@ func TestMySQL(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) }) Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() { diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 05e39f2c762..0a2ea1d2af6 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -3,7 +3,6 @@ package postgres import ( "fmt" "regexp" - "strconv" "strings" "time" @@ -14,22 +13,40 @@ import ( const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` -type PostgresMacroEngine struct { - TimeRange *tsdb.TimeRange - Query *tsdb.Query +type postgresMacroEngine struct { + timeRange *tsdb.TimeRange + query *tsdb.Query + timescaledb bool } -func NewPostgresMacroEngine() tsdb.SqlMacroEngine { - return &PostgresMacroEngine{} +func newPostgresMacroEngine(timescaledb bool) tsdb.SqlMacroEngine { + return &postgresMacroEngine{timescaledb: timescaledb} } -func (m *PostgresMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { - m.TimeRange = timeRange - m.Query = query +func (m *postgresMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + m.timeRange = timeRange + m.query = query rExp, _ := regexp.Compile(sExpr) var macroError error sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + + // detect if $__timeGroup is supposed to add AS time for pre 5.3 compatibility + // if there is a ',' directly after the macro call $__timeGroup is probably used + // in the old way. Inside window function ORDER BY $__timeGroup will be followed + // by ')' + if groups[1] == "__timeGroup" { + if index := strings.Index(sql, groups[0]); index >= 0 { + index += len(groups[0]) + if len(sql) > index { + // check for character after macro expression + if sql[index] == ',' { + groups[1] = "__timeGroupAlias" + } + } + } + } + args := strings.Split(groups[2], ",") for i, arg := range args { args[i] = strings.Trim(arg, " ") @@ -66,7 +83,7 @@ func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]str return result + str[lastIndex:] } -func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string, error) { +func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__time": if len(args) == 0 { @@ -83,11 +100,11 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string, return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeFrom": - return fmt.Sprintf("'%s'", m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil + return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil case "__timeTo": - return fmt.Sprintf("'%s'", m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) @@ -97,28 +114,53 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string, return "", fmt.Errorf("error parsing interval %v", args[1]) } if len(args) == 3 { - m.Query.Model.Set("fill", true) - m.Query.Model.Set("fillInterval", interval.Seconds()) - if args[2] == "NULL" { - m.Query.Model.Set("fillNull", true) - } else { - floatVal, err := strconv.ParseFloat(args[2], 64) - if err != nil { - return "", fmt.Errorf("error parsing fill value %v", args[2]) - } - m.Query.Model.Set("fillValue", floatVal) + err := tsdb.SetupFillmode(m.query, interval, args[2]) + if err != nil { + return "", err } } - return fmt.Sprintf("(extract(epoch from %s)/%v)::bigint*%v AS time", args[0], interval.Seconds(), interval.Seconds()), nil + + if m.timescaledb { + return fmt.Sprintf("time_bucket('%vs',%s)", interval.Seconds(), args[0]), nil + } else { + return fmt.Sprintf("floor(extract(epoch from %s)/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil + } + case "__timeGroupAlias": + tg, err := m.evaluateMacro("__timeGroup", args) + if err == nil { + return tg + " AS \"time\"", err + } + return "", err case "__unixEpochFilter": if len(args) == 0 { return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil case "__unixEpochFrom": - return fmt.Sprintf("%d", m.TimeRange.GetFromAsSecondsEpoch()), nil + return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil case "__unixEpochTo": - return fmt.Sprintf("%d", m.TimeRange.GetToAsSecondsEpoch()), nil + return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil + case "__unixEpochGroup": + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) + } + interval, err := time.ParseDuration(strings.Trim(args[1], `'`)) + if err != nil { + return "", fmt.Errorf("error parsing interval %v", args[1]) + } + if len(args) == 3 { + err := tsdb.SetupFillmode(m.query, interval, args[2]) + if err != nil { + return "", err + } + } + return fmt.Sprintf("floor(%s/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil + case "__unixEpochGroupAlias": + tg, err := m.evaluateMacro("__unixEpochGroup", args) + if err == nil { + return tg + " AS \"time\"", err + } + return "", err default: return "", fmt.Errorf("Unknown macro %v", name) } diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go index c3c15691e42..b0b7a28ddd4 100644 --- a/pkg/tsdb/postgres/macros_test.go +++ b/pkg/tsdb/postgres/macros_test.go @@ -12,7 +12,10 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { - engine := NewPostgresMacroEngine() + timescaledbEnabled := false + engine := newPostgresMacroEngine(timescaledbEnabled) + timescaledbEnabled = true + engineTS := newPostgresMacroEngine(timescaledbEnabled) query := &tsdb.Query{} Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { @@ -48,20 +51,55 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) }) - Convey("interpolate __timeGroup function", func() { + Convey("interpolate __timeGroup function pre 5.3 compatibility", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m'), value") So(err, ShouldBeNil) - So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time") + So(sql, ShouldEqual, "SELECT floor(extract(epoch from time_column)/300)*300 AS \"time\", value") + + sql, err = engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m') as time, value") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "SELECT floor(extract(epoch from time_column)/300)*300 as time, value") + }) + + Convey("interpolate __timeGroup function", func() { + + sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m')") + So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroupAlias(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "SELECT floor(extract(epoch from time_column)/300)*300") + So(sql2, ShouldEqual, sql+" AS \"time\"") }) Convey("interpolate __timeGroup function with spaces between args", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") + sql, err := engine.Interpolate(query, timeRange, "$__timeGroup(time_column , '5m')") + So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "$__timeGroupAlias(time_column , '5m')") So(err, ShouldBeNil) - So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time") + So(sql, ShouldEqual, "floor(extract(epoch from time_column)/300)*300") + So(sql2, ShouldEqual, sql+" AS \"time\"") + }) + + Convey("interpolate __timeGroup function with TimescaleDB enabled", func() { + + sql, err := engineTS.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)") + }) + + Convey("interpolate __timeGroup function with spaces between args and TimescaleDB enabled", func() { + + sql, err := engineTS.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)") }) Convey("interpolate __timeTo function", func() { @@ -91,6 +129,18 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix())) }) + + Convey("interpolate __unixEpochGroup function", func() { + + sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") + So(err, ShouldBeNil) + sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "SELECT floor(time_column/300)*300") + So(sql2, ShouldEqual, sql+" AS \"time\"") + }) + }) Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index fdf09216e51..4bcf06638f4 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -1,46 +1,40 @@ package postgres import ( - "container/list" - "context" - "fmt" - "math" + "database/sql" "net/url" "strconv" "github.com/go-xorm/core" - "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" ) -type PostgresQueryEndpoint struct { - sqlEngine tsdb.SqlEngine - log log.Logger -} - func init() { - tsdb.RegisterTsdbQueryEndpoint("postgres", NewPostgresQueryEndpoint) + tsdb.RegisterTsdbQueryEndpoint("postgres", newPostgresQueryEndpoint) } -func NewPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - endpoint := &PostgresQueryEndpoint{ - log: log.New("tsdb.postgres"), - } - - endpoint.sqlEngine = &tsdb.DefaultSqlEngine{ - MacroEngine: NewPostgresMacroEngine(), - } +func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + logger := log.New("tsdb.postgres") cnnstr := generateConnectionString(datasource) - endpoint.log.Debug("getEngine", "connection", cnnstr) + logger.Debug("getEngine", "connection", cnnstr) - if err := endpoint.sqlEngine.InitEngine("postgres", datasource, cnnstr); err != nil { - return nil, err + config := tsdb.SqlQueryEndpointConfiguration{ + DriverName: "postgres", + ConnectionString: cnnstr, + Datasource: datasource, + MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"}, } - return endpoint, nil + rowTransformer := postgresRowTransformer{ + log: logger, + } + + timescaledb := datasource.JsonData.Get("timescaledb").MustBool(false) + + return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newPostgresMacroEngine(timescaledb), logger) } func generateConnectionString(datasource *models.DataSource) string { @@ -53,74 +47,25 @@ func generateConnectionString(datasource *models.DataSource) string { } sslmode := datasource.JsonData.Get("sslmode").MustString("verify-full") - u := &url.URL{Scheme: "postgres", User: url.UserPassword(datasource.User, password), Host: datasource.Url, Path: datasource.Database, RawQuery: "sslmode=" + sslmode} + u := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(datasource.User, password), + Host: datasource.Url, Path: datasource.Database, + RawQuery: "sslmode=" + url.QueryEscape(sslmode), + } + return u.String() } -func (e *PostgresQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { - return e.sqlEngine.Query(ctx, dsInfo, tsdbQuery, e.transformToTimeSeries, e.transformToTable) +type postgresRowTransformer struct { + log log.Logger } -func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { - columnNames, err := rows.Columns() - if err != nil { - return err - } +func (t *postgresRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) { + values := make([]interface{}, len(columnTypes)) + valuePtrs := make([]interface{}, len(columnTypes)) - table := &tsdb.Table{ - Columns: make([]tsdb.TableColumn, len(columnNames)), - Rows: make([]tsdb.RowValues, 0), - } - - for i, name := range columnNames { - table.Columns[i].Text = name - } - - rowLimit := 1000000 - rowCount := 0 - timeIndex := -1 - - // check if there is a column named time - for i, col := range columnNames { - switch col { - case "time": - timeIndex = i - } - } - - for ; rows.Next(); rowCount++ { - if rowCount > rowLimit { - return fmt.Errorf("PostgreSQL query row limit exceeded, limit %d", rowLimit) - } - - values, err := e.getTypedRowData(rows) - if err != nil { - return err - } - - // converts column named time to unix timestamp in milliseconds to make - // native postgres datetime types and epoch dates work in - // annotation and table queries. - tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) - - table.Rows = append(table.Rows, values) - } - - result.Tables = append(result.Tables, table) - result.Meta.Set("rowCount", rowCount) - return nil -} - -func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, error) { - types, err := rows.ColumnTypes() - if err != nil { - return nil, err - } - - values := make([]interface{}, len(types)) - valuePtrs := make([]interface{}, len(types)) - - for i := 0; i < len(types); i++ { + for i := 0; i < len(columnTypes); i++ { valuePtrs[i] = &values[i] } @@ -130,20 +75,20 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, // convert types not handled by lib/pq // unhandled types are returned as []byte - for i := 0; i < len(types); i++ { + for i := 0; i < len(columnTypes); i++ { if value, ok := values[i].([]byte); ok { - switch types[i].DatabaseTypeName() { + switch columnTypes[i].DatabaseTypeName() { case "NUMERIC": if v, err := strconv.ParseFloat(string(value), 64); err == nil { values[i] = v } else { - e.log.Debug("Rows", "Error converting numeric to float", value) + t.log.Debug("Rows", "Error converting numeric to float", value) } case "UNKNOWN", "CIDR", "INET", "MACADDR": // char literals have type UNKNOWN values[i] = string(value) default: - e.log.Debug("Rows", "Unknown database type", types[i].DatabaseTypeName(), "value", value) + t.log.Debug("Rows", "Unknown database type", columnTypes[i].DatabaseTypeName(), "value", value) values[i] = string(value) } } @@ -151,159 +96,3 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, return values, nil } - -func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult, tsdbQuery *tsdb.TsdbQuery) error { - pointsBySeries := make(map[string]*tsdb.TimeSeries) - seriesByQueryOrder := list.New() - - columnNames, err := rows.Columns() - if err != nil { - return err - } - - columnTypes, err := rows.ColumnTypes() - if err != nil { - return err - } - - rowLimit := 1000000 - rowCount := 0 - timeIndex := -1 - metricIndex := -1 - - // check columns of resultset: a column named time is mandatory - // the first text column is treated as metric name unless a column named metric is present - for i, col := range columnNames { - switch col { - case "time": - timeIndex = i - case "metric": - metricIndex = i - default: - if metricIndex == -1 { - switch columnTypes[i].DatabaseTypeName() { - case "UNKNOWN", "TEXT", "VARCHAR", "CHAR": - metricIndex = i - } - } - } - } - - if timeIndex == -1 { - return fmt.Errorf("Found no column named time") - } - - fillMissing := query.Model.Get("fill").MustBool(false) - var fillInterval float64 - fillValue := null.Float{} - if fillMissing { - fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 - if !query.Model.Get("fillNull").MustBool(false) { - fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() - fillValue.Valid = true - } - } - - for rows.Next() { - var timestamp float64 - var value null.Float - var metric string - - if rowCount > rowLimit { - return fmt.Errorf("PostgreSQL query row limit exceeded, limit %d", rowLimit) - } - - values, err := e.getTypedRowData(rows) - if err != nil { - return err - } - - // converts column named time to unix timestamp in milliseconds to make - // native mysql datetime types and epoch dates work in - // annotation and table queries. - tsdb.ConvertSqlTimeColumnToEpochMs(values, timeIndex) - - switch columnValue := values[timeIndex].(type) { - case int64: - timestamp = float64(columnValue) - case float64: - timestamp = columnValue - default: - return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) - } - - if metricIndex >= 0 { - if columnValue, ok := values[metricIndex].(string); ok { - metric = columnValue - } else { - return fmt.Errorf("Column metric must be of type char,varchar or text, got: %T %v", values[metricIndex], values[metricIndex]) - } - } - - for i, col := range columnNames { - if i == timeIndex || i == metricIndex { - continue - } - - if value, err = tsdb.ConvertSqlValueColumnToFloat(col, values[i]); err != nil { - return err - } - - if metricIndex == -1 { - metric = col - } - - series, exist := pointsBySeries[metric] - if !exist { - series = &tsdb.TimeSeries{Name: metric} - pointsBySeries[metric] = series - seriesByQueryOrder.PushBack(metric) - } - - if fillMissing { - var intervalStart float64 - if !exist { - intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) - } else { - intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval - } - - // align interval start - intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval - - for i := intervalStart; i < timestamp; i += fillInterval { - series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) - rowCount++ - } - } - - series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) - - e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) - rowCount++ - - } - } - - for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() { - key := elem.Value.(string) - result.Series = append(result.Series, pointsBySeries[key]) - - if fillMissing { - series := pointsBySeries[key] - // fill in values from last fetched value till interval end - intervalStart := series.Points[len(series.Points)-1][1].Float64 - intervalEnd := float64(tsdbQuery.TimeRange.MustGetTo().UnixNano() / 1e6) - - // align interval start - intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval - for i := intervalStart + fillInterval; i < intervalEnd; i += fillInterval { - series.Points = append(series.Points, tsdb.TimePoint{fillValue, null.FloatFrom(i)}) - rowCount++ - } - } - } - - result.Meta.Set("rowCount", rowCount) - return nil -} diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index 7f24d5a2063..4e05f676682 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -8,8 +8,9 @@ import ( "time" "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/tsdb" @@ -22,10 +23,11 @@ import ( // The tests require a PostgreSQL db named grafanadstest and a user/password grafanatest/grafanatest! // Use the docker/blocks/postgres_tests/docker-compose.yaml to spin up a // preconfigured Postgres server suitable for running these tests. -// There is also a dashboard.json in same directory that you can import to Grafana -// once you've created a datasource for the test server/database. +// There is also a datasource and dashboard provisioned by devenv scripts that you can +// use to verify that the generated data are vizualized as expected, see +// devenv/README.md for setup instructions. func TestPostgres(t *testing.T) { - // change to true to run the MySQL tests + // change to true to run the PostgreSQL tests runPostgresTests := false // runPostgresTests := true @@ -36,19 +38,25 @@ func TestPostgres(t *testing.T) { Convey("PostgreSQL", t, func() { x := InitPostgresTestDB(t) - endpoint := &PostgresQueryEndpoint{ - sqlEngine: &tsdb.DefaultSqlEngine{ - MacroEngine: NewPostgresMacroEngine(), - XormEngine: x, - }, - log: log.New("tsdb.postgres"), + origXormEngine := tsdb.NewXormEngine + tsdb.NewXormEngine = func(d, c string) (*xorm.Engine, error) { + return x, nil } - sess := x.NewSession() - defer sess.Close() + endpoint, err := newPostgresQueryEndpoint(&models.DataSource{ + JsonData: simplejson.New(), + SecureJsonData: securejsondata.SecureJsonData{}, + }) + So(err, ShouldBeNil) + sess := x.NewSession() fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) + Reset(func() { + sess.Close() + tsdb.NewXormEngine = origXormEngine + }) + Convey("Given a table with different native data types", func() { sql := ` DROP TABLE IF EXISTS postgres_types; @@ -175,7 +183,7 @@ func TestPostgres(t *testing.T) { Queries: []*tsdb.Query{ { Model: simplejson.NewFromAny(map[string]interface{}{ - "rawSql": "SELECT $__timeGroup(time, '5m'), avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", "format": "time_series", }), RefId: "A", @@ -189,21 +197,23 @@ func TestPostgres(t *testing.T) { So(queryResult.Error, ShouldBeNil) points := queryResult.Series[0].Points - So(len(points), ShouldEqual, 6) + // without fill this should result in 4 buckets + So(len(points), ShouldEqual, 4) dt := fromStart - for i := 0; i < 3; i++ { + for i := 0; i < 2; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 15) So(aTime, ShouldEqual, dt) + So(aTime.Unix()%300, ShouldEqual, 0) dt = dt.Add(5 * time.Minute) } - // adjust for 5 minute gap - dt = dt.Add(5 * time.Minute) - for i := 3; i < 6; i++ { + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 2; i < 4; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 20) @@ -217,7 +227,7 @@ func TestPostgres(t *testing.T) { Queries: []*tsdb.Query{ { Model: simplejson.NewFromAny(map[string]interface{}{ - "rawSql": "SELECT $__timeGroup(time, '5m', NULL), avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroup(time, '5m', NULL) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", "format": "time_series", }), RefId: "A", @@ -239,7 +249,7 @@ func TestPostgres(t *testing.T) { dt := fromStart - for i := 0; i < 3; i++ { + for i := 0; i < 2; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 15) @@ -247,25 +257,31 @@ func TestPostgres(t *testing.T) { dt = dt.Add(5 * time.Minute) } + // check for NULL values inserted by fill + So(points[2][0].Valid, ShouldBeFalse) So(points[3][0].Valid, ShouldBeFalse) - // adjust for 5 minute gap - dt = dt.Add(5 * time.Minute) - for i := 4; i < 7; i++ { + // adjust for 10 minute gap between first and second set of points + dt = dt.Add(10 * time.Minute) + for i := 4; i < 6; i++ { aValue := points[i][0].Float64 aTime := time.Unix(int64(points[i][1].Float64)/1000, 0) So(aValue, ShouldEqual, 20) So(aTime, ShouldEqual, dt) dt = dt.Add(5 * time.Minute) } + + // check for NULL values inserted by fill + So(points[6][0].Valid, ShouldBeFalse) + }) - Convey("When doing a metric query using timeGroup with float fill enabled", func() { + Convey("When doing a metric query using timeGroup with value fill enabled", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { Model: simplejson.NewFromAny(map[string]interface{}{ - "rawSql": "SELECT $__timeGroup(time, '5m', 1.5), avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "rawSql": "SELECT $__timeGroup(time, '5m', 1.5) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", "format": "time_series", }), RefId: "A", @@ -287,6 +303,34 @@ func TestPostgres(t *testing.T) { }) }) + Convey("When doing a metric query using timeGroup with previous fill enabled", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, '5m', previous), avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + points := queryResult.Series[0].Points + So(points[2][0].Float64, ShouldEqual, 15.0) + So(points[3][0].Float64, ShouldEqual, 15.0) + So(points[6][0].Float64, ShouldEqual, 20.0) + }) + Convey("Given a table with metrics having multiple values and measurements", func() { type metric_values struct { Time time.Time @@ -504,7 +548,7 @@ func TestPostgres(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 1) - So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3) }) Convey("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable) should return metric with time in milliseconds", func() { @@ -526,7 +570,7 @@ func TestPostgres(t *testing.T) { So(queryResult.Error, ShouldBeNil) So(len(queryResult.Series), ShouldEqual, 1) - So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3) + So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3) }) Convey("When doing a metric query grouping by time and select metric column should return correct series", func() { @@ -552,6 +596,31 @@ func TestPostgres(t *testing.T) { So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one") }) + Convey("When doing a metric query with metric column and multiple value columns", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": `SELECT $__timeEpoch(time), measurement as metric, "valueOne", "valueTwo" FROM metric_values ORDER BY 1`, + "format": "time_series", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + + So(len(queryResult.Series), ShouldEqual, 4) + So(queryResult.Series[0].Name, ShouldEqual, "Metric A valueOne") + So(queryResult.Series[1].Name, ShouldEqual, "Metric A valueTwo") + So(queryResult.Series[2].Name, ShouldEqual, "Metric B valueOne") + So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo") + }) + Convey("When doing a metric query grouping by time should return correct series", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ @@ -713,7 +782,7 @@ func TestPostgres(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) }) Convey("When doing an annotation query with a time column in epoch second format (int) should return ms", func() { @@ -743,7 +812,7 @@ func TestPostgres(t *testing.T) { columns := queryResult.Tables[0].Rows[0] //Should be in milliseconds - So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000)) + So(columns[0].(int64), ShouldEqual, dt.Unix()*1000) }) Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() { diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go index 82a9b8f0d88..454853c7cc8 100644 --- a/pkg/tsdb/sql_engine.go +++ b/pkg/tsdb/sql_engine.go @@ -1,11 +1,18 @@ package tsdb import ( + "container/list" "context" + "database/sql" "fmt" + "math" + "strconv" + "strings" "sync" "time" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/components/null" "github.com/go-xorm/core" @@ -14,27 +21,15 @@ import ( "github.com/grafana/grafana/pkg/models" ) -// SqlEngine is a wrapper class around xorm for relational database data sources. -type SqlEngine interface { - InitEngine(driverName string, dsInfo *models.DataSource, cnnstr string) error - Query( - ctx context.Context, - ds *models.DataSource, - query *TsdbQuery, - transformToTimeSeries func(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error, - transformToTable func(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error, - ) (*Response, error) -} - // SqlMacroEngine interpolates macros into sql. It takes in the Query to have access to query context and // timeRange to be able to generate queries that use from and to. type SqlMacroEngine interface { Interpolate(query *Query, timeRange *TimeRange, sql string) (string, error) } -type DefaultSqlEngine struct { - MacroEngine SqlMacroEngine - XormEngine *xorm.Engine +// SqlTableRowTransformer transforms a query result row to RowValues with proper types. +type SqlTableRowTransformer interface { + Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (RowValues, error) } type engineCacheType struct { @@ -48,68 +43,98 @@ var engineCache = engineCacheType{ versions: make(map[int64]int), } -// InitEngine creates the db connection and inits the xorm engine or loads it from the engine cache -func (e *DefaultSqlEngine) InitEngine(driverName string, dsInfo *models.DataSource, cnnstr string) error { +var NewXormEngine = func(driverName string, connectionString string) (*xorm.Engine, error) { + return xorm.NewEngine(driverName, connectionString) +} + +type sqlQueryEndpoint struct { + macroEngine SqlMacroEngine + rowTransformer SqlTableRowTransformer + engine *xorm.Engine + timeColumnNames []string + metricColumnTypes []string + log log.Logger +} + +type SqlQueryEndpointConfiguration struct { + DriverName string + Datasource *models.DataSource + ConnectionString string + TimeColumnNames []string + MetricColumnTypes []string +} + +var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, rowTransformer SqlTableRowTransformer, macroEngine SqlMacroEngine, log log.Logger) (TsdbQueryEndpoint, error) { + queryEndpoint := sqlQueryEndpoint{ + rowTransformer: rowTransformer, + macroEngine: macroEngine, + timeColumnNames: []string{"time"}, + log: log, + } + + if len(config.TimeColumnNames) > 0 { + queryEndpoint.timeColumnNames = config.TimeColumnNames + } + + if len(config.MetricColumnTypes) > 0 { + queryEndpoint.metricColumnTypes = config.MetricColumnTypes + } + engineCache.Lock() defer engineCache.Unlock() - if engine, present := engineCache.cache[dsInfo.Id]; present { - if version := engineCache.versions[dsInfo.Id]; version == dsInfo.Version { - e.XormEngine = engine - return nil + if engine, present := engineCache.cache[config.Datasource.Id]; present { + if version := engineCache.versions[config.Datasource.Id]; version == config.Datasource.Version { + queryEndpoint.engine = engine + return &queryEndpoint, nil } } - engine, err := xorm.NewEngine(driverName, cnnstr) + engine, err := NewXormEngine(config.DriverName, config.ConnectionString) if err != nil { - return err + return nil, err } engine.SetMaxOpenConns(10) engine.SetMaxIdleConns(10) - engineCache.cache[dsInfo.Id] = engine - e.XormEngine = engine + engineCache.versions[config.Datasource.Id] = config.Datasource.Version + engineCache.cache[config.Datasource.Id] = engine + queryEndpoint.engine = engine - return nil + return &queryEndpoint, nil } -// Query is a default implementation of the Query method for an SQL data source. -// The caller of this function must implement transformToTimeSeries and transformToTable and -// pass them in as parameters. -func (e *DefaultSqlEngine) Query( - ctx context.Context, - dsInfo *models.DataSource, - tsdbQuery *TsdbQuery, - transformToTimeSeries func(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error, - transformToTable func(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error, -) (*Response, error) { +const rowLimit = 1000000 + +// Query is the main function for the SqlQueryEndpoint +func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *TsdbQuery) (*Response, error) { result := &Response{ Results: make(map[string]*QueryResult), } - session := e.XormEngine.NewSession() + session := e.engine.NewSession() defer session.Close() db := session.DB() for _, query := range tsdbQuery.Queries { - rawSql := query.Model.Get("rawSql").MustString() - if rawSql == "" { + rawSQL := query.Model.Get("rawSql").MustString() + if rawSQL == "" { continue } queryResult := &QueryResult{Meta: simplejson.New(), RefId: query.RefId} result.Results[query.RefId] = queryResult - rawSql, err := e.MacroEngine.Interpolate(query, tsdbQuery.TimeRange, rawSql) + rawSQL, err := e.macroEngine.Interpolate(query, tsdbQuery.TimeRange, rawSQL) if err != nil { queryResult.Error = err continue } - queryResult.Meta.Set("sql", rawSql) + queryResult.Meta.Set("sql", rawSQL) - rows, err := db.Query(rawSql) + rows, err := db.Query(rawSQL) if err != nil { queryResult.Error = err continue @@ -121,13 +146,13 @@ func (e *DefaultSqlEngine) Query( switch format { case "time_series": - err := transformToTimeSeries(query, rows, queryResult, tsdbQuery) + err := e.transformToTimeSeries(query, rows, queryResult, tsdbQuery) if err != nil { queryResult.Error = err continue } case "table": - err := transformToTable(query, rows, queryResult, tsdbQuery) + err := e.transformToTable(query, rows, queryResult, tsdbQuery) if err != nil { queryResult.Error = err continue @@ -138,6 +163,256 @@ func (e *DefaultSqlEngine) Query( return result, nil } +func (e *sqlQueryEndpoint) transformToTable(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error { + columnNames, err := rows.Columns() + columnCount := len(columnNames) + + if err != nil { + return err + } + + rowCount := 0 + timeIndex := -1 + + table := &Table{ + Columns: make([]TableColumn, columnCount), + Rows: make([]RowValues, 0), + } + + for i, name := range columnNames { + table.Columns[i].Text = name + + for _, tc := range e.timeColumnNames { + if name == tc { + timeIndex = i + break + } + } + } + + columnTypes, err := rows.ColumnTypes() + if err != nil { + return err + } + + for ; rows.Next(); rowCount++ { + if rowCount > rowLimit { + return fmt.Errorf("query row limit exceeded, limit %d", rowLimit) + } + + values, err := e.rowTransformer.Transform(columnTypes, rows) + if err != nil { + return err + } + + // converts column named time to unix timestamp in milliseconds + // to make native mssql datetime types and epoch dates work in + // annotation and table queries. + ConvertSqlTimeColumnToEpochMs(values, timeIndex) + table.Rows = append(table.Rows, values) + } + + result.Tables = append(result.Tables, table) + result.Meta.Set("rowCount", rowCount) + return nil +} + +func (e *sqlQueryEndpoint) transformToTimeSeries(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error { + pointsBySeries := make(map[string]*TimeSeries) + seriesByQueryOrder := list.New() + + columnNames, err := rows.Columns() + if err != nil { + return err + } + + columnTypes, err := rows.ColumnTypes() + if err != nil { + return err + } + + rowCount := 0 + timeIndex := -1 + metricIndex := -1 + metricPrefix := false + var metricPrefixValue string + + // check columns of resultset: a column named time is mandatory + // the first text column is treated as metric name unless a column named metric is present + for i, col := range columnNames { + for _, tc := range e.timeColumnNames { + if col == tc { + timeIndex = i + continue + } + } + switch col { + case "metric": + metricIndex = i + default: + if metricIndex == -1 { + columnType := columnTypes[i].DatabaseTypeName() + + for _, mct := range e.metricColumnTypes { + if columnType == mct { + metricIndex = i + continue + } + } + } + } + } + + // use metric column as prefix with multiple value columns + if metricIndex != -1 && len(columnNames) > 3 { + metricPrefix = true + } + + if timeIndex == -1 { + return fmt.Errorf("Found no column named %s", strings.Join(e.timeColumnNames, " or ")) + } + + fillMissing := query.Model.Get("fill").MustBool(false) + var fillInterval float64 + fillValue := null.Float{} + fillPrevious := false + + if fillMissing { + fillInterval = query.Model.Get("fillInterval").MustFloat64() * 1000 + switch query.Model.Get("fillMode").MustString() { + case "null": + case "previous": + fillPrevious = true + case "value": + fillValue.Float64 = query.Model.Get("fillValue").MustFloat64() + fillValue.Valid = true + } + } + + for rows.Next() { + var timestamp float64 + var value null.Float + var metric string + + if rowCount > rowLimit { + return fmt.Errorf("query row limit exceeded, limit %d", rowLimit) + } + + values, err := e.rowTransformer.Transform(columnTypes, rows) + if err != nil { + return err + } + + // converts column named time to unix timestamp in milliseconds to make + // native mysql datetime types and epoch dates work in + // annotation and table queries. + ConvertSqlTimeColumnToEpochMs(values, timeIndex) + + switch columnValue := values[timeIndex].(type) { + case int64: + timestamp = float64(columnValue) + case float64: + timestamp = columnValue + default: + return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp, got: %T %v", columnValue, columnValue) + } + + if metricIndex >= 0 { + if columnValue, ok := values[metricIndex].(string); ok { + if metricPrefix { + metricPrefixValue = columnValue + } else { + metric = columnValue + } + } else { + return fmt.Errorf("Column metric must be of type %s. metric column name: %s type: %s but datatype is %T", strings.Join(e.metricColumnTypes, ", "), columnNames[metricIndex], columnTypes[metricIndex].DatabaseTypeName(), values[metricIndex]) + } + } + + for i, col := range columnNames { + if i == timeIndex || i == metricIndex { + continue + } + + if value, err = ConvertSqlValueColumnToFloat(col, values[i]); err != nil { + return err + } + + if metricIndex == -1 { + metric = col + } else if metricPrefix { + metric = metricPrefixValue + " " + col + } + + series, exist := pointsBySeries[metric] + if !exist { + series = &TimeSeries{Name: metric} + pointsBySeries[metric] = series + seriesByQueryOrder.PushBack(metric) + } + + if fillMissing { + var intervalStart float64 + if !exist { + intervalStart = float64(tsdbQuery.TimeRange.MustGetFrom().UnixNano() / 1e6) + } else { + intervalStart = series.Points[len(series.Points)-1][1].Float64 + fillInterval + } + + if fillPrevious { + if len(series.Points) > 0 { + fillValue = series.Points[len(series.Points)-1][0] + } else { + fillValue.Valid = false + } + } + + // align interval start + intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval + + for i := intervalStart; i < timestamp; i += fillInterval { + series.Points = append(series.Points, TimePoint{fillValue, null.FloatFrom(i)}) + rowCount++ + } + } + + series.Points = append(series.Points, TimePoint{value, null.FloatFrom(timestamp)}) + + e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) + } + } + + for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() { + key := elem.Value.(string) + result.Series = append(result.Series, pointsBySeries[key]) + + if fillMissing { + series := pointsBySeries[key] + // fill in values from last fetched value till interval end + intervalStart := series.Points[len(series.Points)-1][1].Float64 + intervalEnd := float64(tsdbQuery.TimeRange.MustGetTo().UnixNano() / 1e6) + + if fillPrevious { + if len(series.Points) > 0 { + fillValue = series.Points[len(series.Points)-1][0] + } else { + fillValue.Valid = false + } + } + + // align interval start + intervalStart = math.Floor(intervalStart/fillInterval) * fillInterval + for i := intervalStart + fillInterval; i < intervalEnd; i += fillInterval { + series.Points = append(series.Points, TimePoint{fillValue, null.FloatFrom(i)}) + rowCount++ + } + } + } + + result.Meta.Set("rowCount", rowCount) + return nil +} + // ConvertSqlTimeColumnToEpochMs converts column named time to unix timestamp in milliseconds // to make native datetime types and epoch dates work in annotation and table queries. func ConvertSqlTimeColumnToEpochMs(values RowValues, timeIndex int) { @@ -294,3 +569,23 @@ func ConvertSqlValueColumnToFloat(columnName string, columnValue interface{}) (n return value, nil } + +func SetupFillmode(query *Query, interval time.Duration, fillmode string) error { + query.Model.Set("fill", true) + query.Model.Set("fillInterval", interval.Seconds()) + switch fillmode { + case "NULL": + query.Model.Set("fillMode", "null") + case "previous": + query.Model.Set("fillMode", "previous") + default: + query.Model.Set("fillMode", "value") + floatVal, err := strconv.ParseFloat(fillmode, 64) + if err != nil { + return fmt.Errorf("error parsing fill value %v", fillmode) + } + query.Model.Set("fillValue", floatVal) + } + + return nil +} diff --git a/pkg/tsdb/testdata/testdata.go b/pkg/tsdb/testdata/testdata.go index a1ab250ad37..c2c2ea3f696 100644 --- a/pkg/tsdb/testdata/testdata.go +++ b/pkg/tsdb/testdata/testdata.go @@ -21,7 +21,7 @@ func NewTestDataExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, err } func init() { - tsdb.RegisterTsdbQueryEndpoint("grafana-testdata-datasource", NewTestDataExecutor) + tsdb.RegisterTsdbQueryEndpoint("testdata", NewTestDataExecutor) } func (e *TestDataExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { diff --git a/pkg/util/url.go b/pkg/util/url.go index c82dcef67c5..fad2d79a6d0 100644 --- a/pkg/util/url.go +++ b/pkg/util/url.go @@ -10,7 +10,7 @@ type UrlQueryReader struct { } func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) { - u, err := url.ParseQuery(urlInfo.String()) + u, err := url.ParseQuery(urlInfo.RawQuery) if err != nil { return nil, err } diff --git a/pkg/util/url_test.go b/pkg/util/url_test.go index 4dd221b9e0b..ee29956f60d 100644 --- a/pkg/util/url_test.go +++ b/pkg/util/url_test.go @@ -4,6 +4,7 @@ import ( "testing" . "github.com/smartystreets/goconvey/convey" + "net/url" ) func TestUrl(t *testing.T) { @@ -43,4 +44,30 @@ func TestUrl(t *testing.T) { So(result, ShouldEqual, "http://localhost:8080/api/") }) + + Convey("When joining two urls where lefthand side has a trailing slash and righthand side has preceding slash", t, func() { + result := JoinUrlFragments("http://localhost:8080/", "/api/") + + So(result, ShouldEqual, "http://localhost:8080/api/") + }) +} + +func TestNewUrlQueryReader(t *testing.T) { + u, _ := url.Parse("http://www.abc.com/foo?bar=baz&bar2=baz2") + uqr, _ := NewUrlQueryReader(u) + + Convey("when trying to retrieve the first query value", t, func() { + result := uqr.Get("bar", "foodef") + So(result, ShouldEqual, "baz") + }) + + Convey("when trying to retrieve the second query value", t, func() { + result := uqr.Get("bar2", "foodef") + So(result, ShouldEqual, "baz2") + }) + + Convey("when trying to retrieve from a non-existent key, the default value is returned", t, func() { + result := uqr.Get("bar3", "foodef") + So(result, ShouldEqual, "foodef") + }) } diff --git a/pkg/util/validation_test.go b/pkg/util/validation_test.go new file mode 100644 index 00000000000..124da1b744b --- /dev/null +++ b/pkg/util/validation_test.go @@ -0,0 +1,22 @@ +package util + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestIsEmail(t *testing.T) { + + Convey("When validating a string that is a valid email", t, func() { + result := IsEmail("abc@def.com") + + So(result, ShouldEqual, true) + }) + + Convey("When validating a string that is not a valid email", t, func() { + result := IsEmail("abcdef.com") + + So(result, ShouldEqual, false) + }) +} diff --git a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx b/public/app/containers/AlertRuleList/AlertRuleList.test.tsx similarity index 100% rename from public/app/containers/AlertRuleList/AlertRuleList.jest.tsx rename to public/app/containers/AlertRuleList/AlertRuleList.test.tsx diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap similarity index 100% rename from public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap rename to public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap diff --git a/public/app/containers/Explore/Explore.tsx b/public/app/containers/Explore/Explore.tsx index deebe84f2c8..9620ac4f91b 100644 --- a/public/app/containers/Explore/Explore.tsx +++ b/public/app/containers/Explore/Explore.tsx @@ -1,16 +1,33 @@ import React from 'react'; import { hot } from 'react-hot-loader'; +import Select from 'react-select'; + +import kbn from 'app/core/utils/kbn'; import colors from 'app/core/utils/colors'; +import store from 'app/core/store'; import TimeSeries from 'app/core/time_series2'; +import { decodePathComponent } from 'app/core/utils/location_util'; +import { parse as parseDate } from 'app/core/utils/datemath'; import ElapsedTime from './ElapsedTime'; import QueryRows from './QueryRows'; import Graph from './Graph'; +import Logs from './Logs'; import Table from './Table'; import TimePicker, { DEFAULT_RANGE } from './TimePicker'; -import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query'; -import { decodePathComponent } from 'app/core/utils/location_util'; +import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; + +const MAX_HISTORY_ITEMS = 100; + +function makeHints(hints) { + const hintsByIndex = []; + hints.forEach(hint => { + if (hint) { + hintsByIndex[hint.index] = hint; + } + }); + return hintsByIndex; +} function makeTimeSeriesList(dataList, options) { return dataList.map((seriesData, index) => { @@ -30,75 +47,151 @@ function makeTimeSeriesList(dataList, options) { }); } -function parseInitialState(initial) { - try { - const parsed = JSON.parse(decodePathComponent(initial)); - return { - queries: parsed.queries.map(q => q.query), - range: parsed.range, - }; - } catch (e) { - console.error(e); - return { queries: [], range: DEFAULT_RANGE }; +function parseUrlState(initial: string | undefined) { + if (initial) { + try { + const parsed = JSON.parse(decodePathComponent(initial)); + return { + datasource: parsed.datasource, + queries: parsed.queries.map(q => q.query), + range: parsed.range, + }; + } catch (e) { + console.error(e); + } } + return { datasource: null, queries: [], range: DEFAULT_RANGE }; } interface IExploreState { datasource: any; datasourceError: any; - datasourceLoading: any; + datasourceLoading: boolean | null; + datasourceMissing: boolean; graphResult: any; + history: any[]; + initialDatasource?: string; latency: number; loading: any; - queries: any; - queryError: any; + logsResult: any; + queries: any[]; + queryErrors: any[]; + queryHints: any[]; range: any; requestOptions: any; showingGraph: boolean; + showingLogs: boolean; showingTable: boolean; + supportsGraph: boolean | null; + supportsLogs: boolean | null; + supportsTable: boolean | null; tableResult: any; } -// @observer export class Explore extends React.Component { - datasourceSrv: DatasourceSrv; + el: any; constructor(props) { super(props); - const { range, queries } = parseInitialState(props.routeParams.initial); + const initialState: IExploreState = props.initialState; + const { datasource, queries, range } = parseUrlState(props.routeParams.state); this.state = { datasource: null, datasourceError: null, - datasourceLoading: true, + datasourceLoading: null, + datasourceMissing: false, graphResult: null, + initialDatasource: datasource, + history: [], latency: 0, loading: false, + logsResult: null, queries: ensureQueries(queries), - queryError: null, + queryErrors: [], + queryHints: [], range: range || { ...DEFAULT_RANGE }, requestOptions: null, showingGraph: true, + showingLogs: true, showingTable: true, + supportsGraph: null, + supportsLogs: null, + supportsTable: null, tableResult: null, - ...props.initialState, + ...initialState, }; } async componentDidMount() { - const datasource = await this.props.datasourceSrv.get(); - const testResult = await datasource.testDatasource(); - if (testResult.status === 'success') { - this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit()); + const { datasourceSrv } = this.props; + const { initialDatasource } = this.state; + if (!datasourceSrv) { + throw new Error('No datasource service passed as props.'); + } + const datasources = datasourceSrv.getExploreSources(); + if (datasources.length > 0) { + this.setState({ datasourceLoading: true }); + // Priority: datasource in url, default datasource, first explore datasource + let datasource; + if (initialDatasource) { + datasource = await datasourceSrv.get(initialDatasource); + } else { + datasource = await datasourceSrv.get(); + } + if (!datasource.meta.explore) { + datasource = await datasourceSrv.get(datasources[0].name); + } + this.setDatasource(datasource); } else { - this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false }); + this.setState({ datasourceMissing: true }); } } componentDidCatch(error) { + this.setState({ datasourceError: error }); console.error(error); } - handleAddQueryRow = index => { + async setDatasource(datasource) { + const supportsGraph = datasource.meta.metrics; + const supportsLogs = datasource.meta.logs; + const supportsTable = datasource.meta.metrics; + const datasourceId = datasource.meta.id; + let datasourceError = null; + + try { + const testResult = await datasource.testDatasource(); + datasourceError = testResult.status === 'success' ? null : testResult.message; + } catch (error) { + datasourceError = (error && error.statusText) || error; + } + + const historyKey = `grafana.explore.history.${datasourceId}`; + const history = store.getObject(historyKey, []); + + if (datasource.init) { + datasource.init(); + } + + this.setState( + { + datasource, + datasourceError, + history, + supportsGraph, + supportsLogs, + supportsTable, + datasourceLoading: false, + }, + () => datasourceError === null && this.onSubmit() + ); + } + + getRef = el => { + this.el = el; + }; + + onAddQueryRow = index => { const { queries } = this.state; const nextQueries = [ ...queries.slice(0, index + 1), @@ -108,115 +201,250 @@ export class Explore extends React.Component { this.setState({ queries: nextQueries }); }; - handleChangeQuery = (query, index) => { + onChangeDatasource = async option => { + this.setState({ + datasource: null, + datasourceError: null, + datasourceLoading: true, + graphResult: null, + logsResult: null, + queryErrors: [], + queryHints: [], + tableResult: null, + }); + const datasource = await this.props.datasourceSrv.get(option.value); + this.setDatasource(datasource); + }; + + onChangeQuery = (value: string, index: number, override?: boolean) => { const { queries } = this.state; + let { queryErrors, queryHints } = this.state; + const prevQuery = queries[index]; + const edited = override ? false : prevQuery.query !== value; const nextQuery = { ...queries[index], - query, + edited, + query: value, }; const nextQueries = [...queries]; nextQueries[index] = nextQuery; - this.setState({ queries: nextQueries }); + if (override) { + queryErrors = []; + queryHints = []; + } + this.setState( + { + queryErrors, + queryHints, + queries: nextQueries, + }, + override ? () => this.onSubmit() : undefined + ); }; - handleChangeTime = nextRange => { + onChangeTime = nextRange => { const range = { from: nextRange.from, to: nextRange.to, }; - this.setState({ range }, () => this.handleSubmit()); + this.setState({ range }, () => this.onSubmit()); }; - handleClickCloseSplit = () => { + onClickClear = () => { + this.setState({ + graphResult: null, + logsResult: null, + queries: ensureQueries(), + tableResult: null, + }); + }; + + onClickCloseSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { onChangeSplit(false); } }; - handleClickGraphButton = () => { + onClickGraphButton = () => { this.setState(state => ({ showingGraph: !state.showingGraph })); }; - handleClickSplit = () => { + onClickLogsButton = () => { + this.setState(state => ({ showingLogs: !state.showingLogs })); + }; + + onClickSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { onChangeSplit(true, this.state); } }; - handleClickTableButton = () => { + onClickTableButton = () => { this.setState(state => ({ showingTable: !state.showingTable })); }; - handleRemoveQueryRow = index => { + onClickTableCell = (columnKey: string, rowValue: string) => { + this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); + }; + + onModifyQueries = (action: object, index?: number) => { + const { datasource, queries } = this.state; + if (datasource && datasource.modifyQuery) { + let nextQueries; + if (index === undefined) { + // Modify all queries + nextQueries = queries.map(q => ({ + ...q, + edited: false, + query: datasource.modifyQuery(q.query, action), + })); + } else { + // Modify query only at index + nextQueries = [ + ...queries.slice(0, index), + { + ...queries[index], + edited: false, + query: datasource.modifyQuery(queries[index].query, action), + }, + ...queries.slice(index + 1), + ]; + } + this.setState({ queries: nextQueries }, () => this.onSubmit()); + } + }; + + onRemoveQueryRow = index => { const { queries } = this.state; if (queries.length <= 1) { return; } const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; - this.setState({ queries: nextQueries }, () => this.handleSubmit()); + this.setState({ queries: nextQueries }, () => this.onSubmit()); }; - handleSubmit = () => { - const { showingGraph, showingTable } = this.state; - if (showingTable) { + onSubmit = () => { + const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state; + if (showingTable && supportsTable) { this.runTableQuery(); } - if (showingGraph) { + if (showingGraph && supportsGraph) { this.runGraphQuery(); } + if (showingLogs && supportsLogs) { + this.runLogsQuery(); + } }; - async runGraphQuery() { + onQuerySuccess(datasourceId: string, queries: any[]): void { + // save queries to history + let { datasource, history } = this.state; + if (datasource.meta.id !== datasourceId) { + // Navigated away, queries did not matter + return; + } + const ts = Date.now(); + queries.forEach(q => { + const { query } = q; + history = [{ query, ts }, ...history]; + }); + if (history.length > MAX_HISTORY_ITEMS) { + history = history.slice(0, MAX_HISTORY_ITEMS); + } + // Combine all queries of a datasource type into one history + const historyKey = `grafana.explore.history.${datasourceId}`; + store.setObject(historyKey, history); + this.setState({ history }); + } + + buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) { const { datasource, queries, range } = this.state; + const resolution = this.el.offsetWidth; + const absoluteRange = { + from: parseDate(range.from, false), + to: parseDate(range.to, true), + }; + const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); + const targets = queries.map(q => ({ + ...targetOptions, + expr: q.query, + })); + return { + interval, + range, + targets, + }; + } + + async runGraphQuery() { + const { datasource, queries } = this.state; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, graphResult: null, queryError: null }); + this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] }); const now = Date.now(); - const options = buildQueryOptions({ - format: 'time_series', - interval: datasource.interval, - instant: false, - range, - queries: queries.map(q => q.query), - }); + const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true }); try { const res = await datasource.query(options); const result = makeTimeSeriesList(res.data, options); + const queryHints = res.hints ? makeHints(res.hints) : []; const latency = Date.now() - now; - this.setState({ latency, loading: false, graphResult: result, requestOptions: options }); + this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options }); + this.onQuerySuccess(datasource.meta.id, queries); } catch (response) { console.error(response); const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryError }); + this.setState({ loading: false, queryErrors: [queryError] }); } } async runTableQuery() { - const { datasource, queries, range } = this.state; + const { datasource, queries } = this.state; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, queryError: null, tableResult: null }); + this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null }); const now = Date.now(); - const options = buildQueryOptions({ + const options = this.buildQueryOptions({ format: 'table', - interval: datasource.interval, instant: true, - range, - queries: queries.map(q => q.query), }); try { const res = await datasource.query(options); const tableModel = res.data[0]; const latency = Date.now() - now; this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options }); + this.onQuerySuccess(datasource.meta.id, queries); } catch (response) { console.error(response); const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryError }); + this.setState({ loading: false, queryErrors: [queryError] }); + } + } + + async runLogsQuery() { + const { datasource, queries } = this.state; + if (!hasQuery(queries)) { + return; + } + this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null }); + const now = Date.now(); + const options = this.buildQueryOptions({ + format: 'logs', + }); + + try { + const res = await datasource.query(options); + const logsData = res.data; + const latency = Date.now() - now; + this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options }); + this.onQuerySuccess(datasource.meta.id, queries); + } catch (response) { + console.error(response); + const queryError = response.data ? response.data.error : response; + this.setState({ loading: false, queryErrors: [queryError] }); } } @@ -226,29 +454,44 @@ export class Explore extends React.Component { }; render() { - const { position, split } = this.props; + const { datasourceSrv, position, split } = this.props; const { datasource, datasourceError, datasourceLoading, + datasourceMissing, graphResult, + history, latency, loading, + logsResult, queries, - queryError, + queryErrors, + queryHints, range, requestOptions, showingGraph, + showingLogs, showingTable, + supportsGraph, + supportsLogs, + supportsTable, tableResult, } = this.state; const showingBoth = showingGraph && showingTable; const graphHeight = showingBoth ? '200px' : '400px'; const graphButtonActive = showingBoth || showingGraph ? 'active' : ''; + const logsButtonActive = showingLogs ? 'active' : ''; const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const exploreClass = split ? 'explore explore-split' : 'explore'; + const datasources = datasourceSrv.getExploreSources().map(ds => ({ + value: ds.name, + label: ds.name, + })); + const selectedDatasource = datasource ? datasource.name : undefined; + return ( -
+
{position === 'left' ? (
@@ -259,30 +502,39 @@ export class Explore extends React.Component {
) : (
-
)} + {!datasourceMissing ? ( +
+ +
+ +
+ +
+
+
+ + + {groups.length === 0 && + !isAdding && ( +
+
There are no external groups to sync with
+ +
+ {headerTooltip} + + Learn more + +
+
+ )} + + {groups.length > 0 && ( +
+ + + + + + + {groups.map(group => this.renderGroup(group))} +
External Group ID +
+
+ )} +
+ ); + } +} + +export default hot(module)(TeamGroupSync); diff --git a/public/app/containers/Teams/TeamList.tsx b/public/app/containers/Teams/TeamList.tsx new file mode 100644 index 00000000000..31406250cb3 --- /dev/null +++ b/public/app/containers/Teams/TeamList.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { hot } from 'react-hot-loader'; +import { inject, observer } from 'mobx-react'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import { NavStore } from 'app/stores/NavStore/NavStore'; +import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore'; +import { BackendSrv } from 'app/core/services/backend_srv'; +import DeleteButton from 'app/core/components/DeleteButton/DeleteButton'; + +interface Props { + nav: typeof NavStore.Type; + teams: typeof TeamsStore.Type; + backendSrv: BackendSrv; +} + +@inject('nav', 'teams') +@observer +export class TeamList extends React.Component { + constructor(props) { + super(props); + + this.props.nav.load('cfg', 'teams'); + this.fetchTeams(); + } + + fetchTeams() { + this.props.teams.loadTeams(); + } + + deleteTeam(team: ITeam) { + this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this)); + } + + onSearchQueryChange = evt => { + this.props.teams.setSearchQuery(evt.target.value); + }; + + renderTeamMember(team: ITeam): JSX.Element { + let teamUrl = `org/teams/edit/${team.id}`; + + return ( + + + + + + + + {team.name} + + + {team.email} + + + {team.memberCount} + + + this.deleteTeam(team)} /> + + + ); + } + + render() { + const { nav, teams } = this.props; + return ( +
+ +
+
+
+ +
+ + + +
+ + + + + + + + + {teams.filteredTeams.map(team => this.renderTeamMember(team))} +
+ NameEmailMembers +
+
+
+
+ ); + } +} + +export default hot(module)(TeamList); diff --git a/public/app/containers/Teams/TeamMembers.tsx b/public/app/containers/Teams/TeamMembers.tsx new file mode 100644 index 00000000000..a6b0b04f19d --- /dev/null +++ b/public/app/containers/Teams/TeamMembers.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { hot } from 'react-hot-loader'; +import { observer } from 'mobx-react'; +import { ITeam, ITeamMember } from 'app/stores/TeamsStore/TeamsStore'; +import SlideDown from 'app/core/components/Animations/SlideDown'; +import { UserPicker, User } from 'app/core/components/Picker/UserPicker'; +import DeleteButton from 'app/core/components/DeleteButton/DeleteButton'; + +interface Props { + team: ITeam; +} + +interface State { + isAdding: boolean; + newTeamMember?: User; +} + +@observer +export class TeamMembers extends React.Component { + constructor(props) { + super(props); + this.state = { isAdding: false, newTeamMember: null }; + } + + componentDidMount() { + this.props.team.loadMembers(); + } + + onSearchQueryChange = evt => { + this.props.team.setSearchQuery(evt.target.value); + }; + + removeMember(member: ITeamMember) { + this.props.team.removeMember(member); + } + + removeMemberConfirmed(member: ITeamMember) { + this.props.team.removeMember(member); + } + + renderMember(member: ITeamMember) { + return ( + + + + + {member.login} + {member.email} + + this.removeMember(member)} /> + + + ); + } + + onToggleAdding = () => { + this.setState({ isAdding: !this.state.isAdding }); + }; + + onUserSelected = (user: User) => { + this.setState({ newTeamMember: user }); + }; + + onAddUserToTeam = async () => { + await this.props.team.addMember(this.state.newTeamMember.id); + await this.props.team.loadMembers(); + this.setState({ newTeamMember: null }); + }; + + render() { + const { newTeamMember, isAdding } = this.state; + const members = this.props.team.filteredMembers; + const newTeamMemberValue = newTeamMember && newTeamMember.id.toString(); + const { team } = this.props; + + return ( +
+
+
+ +
+ +
+ + +
+ + +
+ +
Add Team Member
+
+ + + {this.state.newTeamMember && ( + + )} +
+
+
+ +
+ + + + + + + + {members.map(member => this.renderMember(member))} +
+ NameEmail +
+
+
+ ); + } +} + +export default hot(module)(TeamMembers); diff --git a/public/app/containers/Teams/TeamPages.tsx b/public/app/containers/Teams/TeamPages.tsx new file mode 100644 index 00000000000..500a7cbe5e8 --- /dev/null +++ b/public/app/containers/Teams/TeamPages.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import _ from 'lodash'; +import { hot } from 'react-hot-loader'; +import { inject, observer } from 'mobx-react'; +import config from 'app/core/config'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import { NavStore } from 'app/stores/NavStore/NavStore'; +import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore'; +import { ViewStore } from 'app/stores/ViewStore/ViewStore'; +import TeamMembers from './TeamMembers'; +import TeamSettings from './TeamSettings'; +import TeamGroupSync from './TeamGroupSync'; + +interface Props { + nav: typeof NavStore.Type; + teams: typeof TeamsStore.Type; + view: typeof ViewStore.Type; +} + +@inject('nav', 'teams', 'view') +@observer +export class TeamPages extends React.Component { + isSyncEnabled: boolean; + currentPage: string; + + constructor(props) { + super(props); + + this.isSyncEnabled = config.buildInfo.isEnterprise; + this.currentPage = this.getCurrentPage(); + + this.loadTeam(); + } + + async loadTeam() { + const { teams, nav, view } = this.props; + + await teams.loadById(view.routeParams.get('id')); + + nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled); + } + + getCurrentTeam(): ITeam { + const { teams, view } = this.props; + return teams.map.get(view.routeParams.get('id')); + } + + getCurrentPage() { + const pages = ['members', 'settings', 'groupsync']; + const currentPage = this.props.view.routeParams.get('page'); + return _.includes(pages, currentPage) ? currentPage : pages[0]; + } + + render() { + const { nav } = this.props; + const currentTeam = this.getCurrentTeam(); + + if (!nav.main) { + return null; + } + + return ( +
+ + {currentTeam && ( +
+ {this.currentPage === 'members' && } + {this.currentPage === 'settings' && } + {this.currentPage === 'groupsync' && this.isSyncEnabled && } +
+ )} +
+ ); + } +} + +export default hot(module)(TeamPages); diff --git a/public/app/containers/Teams/TeamSettings.tsx b/public/app/containers/Teams/TeamSettings.tsx new file mode 100644 index 00000000000..142088a5d1e --- /dev/null +++ b/public/app/containers/Teams/TeamSettings.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { hot } from 'react-hot-loader'; +import { observer } from 'mobx-react'; +import { ITeam } from 'app/stores/TeamsStore/TeamsStore'; +import { Label } from 'app/core/components/Forms/Forms'; + +interface Props { + team: ITeam; +} + +@observer +export class TeamSettings extends React.Component { + constructor(props) { + super(props); + } + + onChangeName = evt => { + this.props.team.setName(evt.target.value); + }; + + onChangeEmail = evt => { + this.props.team.setEmail(evt.target.value); + }; + + onUpdate = evt => { + evt.preventDefault(); + this.props.team.update(); + }; + + render() { + return ( +
+

Team Settings

+
+
+ + +
+
+ + +
+ +
+ +
+
+
+ ); + } +} + +export default hot(module)(TeamSettings); diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index ace0eb00b07..a4439509f8e 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -5,7 +5,6 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; import LoginBackground from './components/Login/LoginBackground'; import { SearchResult } from './components/search/SearchResult'; import { TagFilter } from './components/TagFilter/TagFilter'; -import UserPicker from './components/Picker/UserPicker'; import DashboardPermissions from './components/Permissions/DashboardPermissions'; export function registerAngularDirectives() { @@ -19,6 +18,5 @@ export function registerAngularDirectives() { ['onSelect', { watchDepth: 'reference' }], ['tagOptions', { watchDepth: 'reference' }], ]); - react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']); react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']); } diff --git a/public/app/core/components/DeleteButton/DeleteButton.test.tsx b/public/app/core/components/DeleteButton/DeleteButton.test.tsx new file mode 100644 index 00000000000..12acadee18a --- /dev/null +++ b/public/app/core/components/DeleteButton/DeleteButton.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import DeleteButton from './DeleteButton'; +import { shallow } from 'enzyme'; + +describe('DeleteButton', () => { + let wrapper; + let deleted; + + beforeAll(() => { + deleted = false; + + function deleteItem() { + deleted = true; + } + wrapper = shallow( deleteItem()} />); + }); + + it('should show confirm delete when clicked', () => { + expect(wrapper.state().showConfirm).toBe(false); + wrapper.find('.delete-button').simulate('click'); + expect(wrapper.state().showConfirm).toBe(true); + }); + + it('should hide confirm delete when clicked', () => { + wrapper.find('.delete-button').simulate('click'); + expect(wrapper.state().showConfirm).toBe(true); + wrapper + .find('.confirm-delete') + .find('.btn') + .at(0) + .simulate('click'); + expect(wrapper.state().showConfirm).toBe(false); + }); + + it('should show confirm delete when clicked', () => { + expect(deleted).toBe(false); + wrapper + .find('.confirm-delete') + .find('.btn') + .at(1) + .simulate('click'); + expect(deleted).toBe(true); + }); +}); diff --git a/public/app/core/components/DeleteButton/DeleteButton.tsx b/public/app/core/components/DeleteButton/DeleteButton.tsx new file mode 100644 index 00000000000..a83ce6097ad --- /dev/null +++ b/public/app/core/components/DeleteButton/DeleteButton.tsx @@ -0,0 +1,66 @@ +import React, { PureComponent } from 'react'; + +export interface DeleteButtonProps { + onConfirmDelete(); +} + +export interface DeleteButtonStates { + showConfirm: boolean; +} + +export default class DeleteButton extends PureComponent { + state: DeleteButtonStates = { + showConfirm: false, + }; + + onClickDelete = event => { + if (event) { + event.preventDefault(); + } + + this.setState({ + showConfirm: true, + }); + }; + + onClickCancel = event => { + if (event) { + event.preventDefault(); + } + this.setState({ + showConfirm: false, + }); + }; + + render() { + const onClickConfirm = this.props.onConfirmDelete; + let showConfirm; + let showDeleteButton; + + if (this.state.showConfirm) { + showConfirm = 'show'; + showDeleteButton = 'hide'; + } else { + showConfirm = 'hide'; + showDeleteButton = 'show'; + } + + return ( + + + + + + + + Cancel + + + Confirm Delete + + + + + ); + } +} diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx similarity index 100% rename from public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx rename to public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx diff --git a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap similarity index 100% rename from public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap rename to public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap diff --git a/public/app/core/components/Forms/Forms.tsx b/public/app/core/components/Forms/Forms.tsx new file mode 100644 index 00000000000..543e1a1d6df --- /dev/null +++ b/public/app/core/components/Forms/Forms.tsx @@ -0,0 +1,21 @@ +import React, { SFC, ReactNode } from 'react'; +import Tooltip from '../Tooltip/Tooltip'; + +interface Props { + tooltip?: string; + for?: string; + children: ReactNode; +} + +export const Label: SFC = props => { + return ( + + {props.children} + {props.tooltip && ( + + + + )} + + ); +}; diff --git a/public/app/core/components/PageHeader/PageHeader.jest.tsx b/public/app/core/components/PageHeader/PageHeader.test.tsx similarity index 100% rename from public/app/core/components/PageHeader/PageHeader.jest.tsx rename to public/app/core/components/PageHeader/PageHeader.test.tsx diff --git a/public/app/core/components/Permissions/AddPermissions.jest.tsx b/public/app/core/components/Permissions/AddPermissions.test.tsx similarity index 68% rename from public/app/core/components/Permissions/AddPermissions.jest.tsx rename to public/app/core/components/Permissions/AddPermissions.test.tsx index fe97c4c7e62..513a22ddea4 100644 --- a/public/app/core/components/Permissions/AddPermissions.jest.tsx +++ b/public/app/core/components/Permissions/AddPermissions.test.tsx @@ -1,32 +1,32 @@ -import React from 'react'; +import React from 'react'; +import { shallow } from 'enzyme'; import AddPermissions from './AddPermissions'; import { RootStore } from 'app/stores/RootStore/RootStore'; -import { backendSrv } from 'test/mocks/common'; -import { shallow } from 'enzyme'; +import { getBackendSrv } from 'app/core/services/backend_srv'; + +jest.mock('app/core/services/backend_srv', () => ({ + getBackendSrv: () => { + return { + get: () => { + return Promise.resolve([ + { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' }, + { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' }, + ]); + }, + post: jest.fn(() => Promise.resolve({})), + }; + }, +})); describe('AddPermissions', () => { let wrapper; let store; let instance; + let backendSrv: any = getBackendSrv(); beforeAll(() => { - backendSrv.get.mockReturnValue( - Promise.resolve([ - { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' }, - { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' }, - ]) - ); - - backendSrv.post = jest.fn(() => Promise.resolve({})); - - store = RootStore.create( - {}, - { - backendSrv: backendSrv, - } - ); - - wrapper = shallow(); + store = RootStore.create({}, { backendSrv: backendSrv }); + wrapper = shallow(); instance = wrapper.instance(); return store.permissions.load(1, true, false); }); @@ -43,8 +43,8 @@ describe('AddPermissions', () => { login: 'user2', }; - instance.typeChanged(evt); - instance.userPicked(userItem); + instance.onTypeChanged(evt); + instance.onUserSelected(userItem); wrapper.update(); @@ -70,8 +70,8 @@ describe('AddPermissions', () => { name: 'ug1', }; - instance.typeChanged(evt); - instance.teamPicked(teamItem); + instance.onTypeChanged(evt); + instance.onTeamSelected(teamItem); wrapper.update(); diff --git a/public/app/core/components/Permissions/AddPermissions.tsx b/public/app/core/components/Permissions/AddPermissions.tsx index 4dcd07ffb48..289e27aa731 100644 --- a/public/app/core/components/Permissions/AddPermissions.tsx +++ b/public/app/core/components/Permissions/AddPermissions.tsx @@ -1,24 +1,19 @@ -import React, { Component } from 'react'; +import React, { Component } from 'react'; import { observer } from 'mobx-react'; import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore'; -import UserPicker, { User } from 'app/core/components/Picker/UserPicker'; -import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker'; +import { UserPicker, User } from 'app/core/components/Picker/UserPicker'; +import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker'; import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker'; import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; -export interface IProps { +export interface Props { permissions: any; - backendSrv: any; } + @observer -class AddPermissions extends Component { +class AddPermissions extends Component { constructor(props) { super(props); - this.userPicked = this.userPicked.bind(this); - this.teamPicked = this.teamPicked.bind(this); - this.permissionPicked = this.permissionPicked.bind(this); - this.typeChanged = this.typeChanged.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); } componentWillMount() { @@ -26,49 +21,49 @@ class AddPermissions extends Component { permissions.resetNewType(); } - typeChanged(evt) { + onTypeChanged = evt => { const { value } = evt.target; const { permissions } = this.props; permissions.setNewType(value); - } + }; - userPicked(user: User) { + onUserSelected = (user: User) => { const { permissions } = this.props; if (!user) { permissions.newItem.setUser(null, null); return; } return permissions.newItem.setUser(user.id, user.login, user.avatarUrl); - } + }; - teamPicked(team: Team) { + onTeamSelected = (team: Team) => { const { permissions } = this.props; if (!team) { permissions.newItem.setTeam(null, null); return; } return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl); - } + }; - permissionPicked(permission: OptionWithDescription) { + onPermissionChanged = (permission: OptionWithDescription) => { const { permissions } = this.props; return permissions.newItem.setPermission(permission.value); - } + }; resetNewType() { const { permissions } = this.props; return permissions.resetNewType(); } - handleSubmit(evt) { + onSubmit = evt => { evt.preventDefault(); const { permissions } = this.props; permissions.addStoreItem(); - } + }; render() { - const { permissions, backendSrv } = this.props; + const { permissions } = this.props; const newItem = permissions.newItem; const pickerClassName = 'width-20'; @@ -79,12 +74,12 @@ class AddPermissions extends Component { -
-
Add Permission For
+ +
Add Permission For
- {aclTypes.map((option, idx) => { return (
- + {
{}} + onSelected={() => {}} value={item.permission} disabled={true} className={'gf-form-input--form-dropdown-right'} diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx index b0158525d52..a17aa8c04df 100644 --- a/public/app/core/components/Permissions/PermissionsListItem.tsx +++ b/public/app/core/components/Permissions/PermissionsListItem.tsx @@ -68,7 +68,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
void; + onSelected: (permission) => void; value: number; disabled: boolean; className?: string; @@ -16,14 +16,14 @@ export interface OptionWithDescription { description: string; } -class DescriptionPicker extends Component { +class DescriptionPicker extends Component { constructor(props) { super(props); this.state = {}; } render() { - const { optionsWithDesc, handlePicked, value, disabled, className } = this.props; + const { optionsWithDesc, onSelected, value, disabled, className } = this.props; return (
@@ -34,7 +34,7 @@ class DescriptionPicker extends Component { clearable={false} labelKey="label" options={optionsWithDesc} - onChange={handlePicked} + onChange={onSelected} className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`} optionComponent={DescriptionOption} placeholder="Choose" diff --git a/public/app/core/components/Picker/PickerOption.jest.tsx b/public/app/core/components/Picker/PickerOption.test.tsx similarity index 100% rename from public/app/core/components/Picker/PickerOption.jest.tsx rename to public/app/core/components/Picker/PickerOption.test.tsx diff --git a/public/app/core/components/Picker/TeamPicker.jest.tsx b/public/app/core/components/Picker/TeamPicker.jest.tsx deleted file mode 100644 index 20b7620e0ac..00000000000 --- a/public/app/core/components/Picker/TeamPicker.jest.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import TeamPicker from './TeamPicker'; - -const model = { - backendSrv: { - get: () => { - return new Promise((resolve, reject) => {}); - }, - }, - handlePicked: () => {}, -}; - -describe('TeamPicker', () => { - it('renders correctly', () => { - const tree = renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/public/app/core/components/Picker/TeamPicker.test.tsx b/public/app/core/components/Picker/TeamPicker.test.tsx new file mode 100644 index 00000000000..3db9f7bb4eb --- /dev/null +++ b/public/app/core/components/Picker/TeamPicker.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { TeamPicker } from './TeamPicker'; + +jest.mock('app/core/services/backend_srv', () => ({ + getBackendSrv: () => { + return { + get: () => { + return Promise.resolve([]); + }, + }; + }, +})); + +describe('TeamPicker', () => { + it('renders correctly', () => { + const props = { + onSelected: () => {}, + }; + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/Picker/TeamPicker.tsx b/public/app/core/components/Picker/TeamPicker.tsx index 2dfff1850dd..04f108ff8da 100644 --- a/public/app/core/components/Picker/TeamPicker.tsx +++ b/public/app/core/components/Picker/TeamPicker.tsx @@ -1,18 +1,19 @@ -import React, { Component } from 'react'; +import React, { Component } from 'react'; import Select from 'react-select'; import PickerOption from './PickerOption'; -import withPicker from './withPicker'; import { debounce } from 'lodash'; +import { getBackendSrv } from 'app/core/services/backend_srv'; -export interface IProps { - backendSrv: any; - isLoading: boolean; - toggleLoading: any; - handlePicked: (user) => void; +export interface Props { + onSelected: (team: Team) => void; value?: string; className?: string; } +export interface State { + isLoading; +} + export interface Team { id: number; label: string; @@ -20,13 +21,12 @@ export interface Team { avatarUrl: string; } -class TeamPicker extends Component { +export class TeamPicker extends Component { debouncedSearch: any; - backendSrv: any; constructor(props) { super(props); - this.state = {}; + this.state = { isLoading: false }; this.search = this.search.bind(this); this.debouncedSearch = debounce(this.search, 300, { @@ -36,9 +36,9 @@ class TeamPicker extends Component { } search(query?: string) { - const { toggleLoading, backendSrv } = this.props; + const backendSrv = getBackendSrv(); + this.setState({ isLoading: true }); - toggleLoading(true); return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => { const teams = result.teams.map(team => { return { @@ -49,18 +49,18 @@ class TeamPicker extends Component { }; }); - toggleLoading(false); + this.setState({ isLoading: false }); return { options: teams }; }); } render() { - const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async; - const { isLoading, handlePicked, value, className } = this.props; + const { onSelected, value, className } = this.props; + const { isLoading } = this.state; return (
- { loadOptions={this.debouncedSearch} loadingPlaceholder="Loading..." noResultsText="No teams found" - onChange={handlePicked} + onChange={onSelected} className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`} optionComponent={PickerOption} - placeholder="Choose" + placeholder="Select a team" value={value} autosize={true} /> @@ -80,5 +80,3 @@ class TeamPicker extends Component { ); } } - -export default withPicker(TeamPicker); diff --git a/public/app/core/components/Picker/UserPicker.jest.tsx b/public/app/core/components/Picker/UserPicker.jest.tsx deleted file mode 100644 index 756fa2d9801..00000000000 --- a/public/app/core/components/Picker/UserPicker.jest.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import UserPicker from './UserPicker'; - -const model = { - backendSrv: { - get: () => { - return new Promise((resolve, reject) => {}); - }, - }, - handlePicked: () => {}, -}; - -describe('UserPicker', () => { - it('renders correctly', () => { - const tree = renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/public/app/core/components/Picker/UserPicker.test.tsx b/public/app/core/components/Picker/UserPicker.test.tsx new file mode 100644 index 00000000000..054ca643700 --- /dev/null +++ b/public/app/core/components/Picker/UserPicker.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { UserPicker } from './UserPicker'; + +jest.mock('app/core/services/backend_srv', () => ({ + getBackendSrv: () => { + return { + get: () => { + return Promise.resolve([]); + }, + }; + }, +})); + +describe('UserPicker', () => { + it('renders correctly', () => { + const tree = renderer.create( {}} />).toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index 77bf6c1fe15..e50513c44e1 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -1,18 +1,19 @@ import React, { Component } from 'react'; import Select from 'react-select'; import PickerOption from './PickerOption'; -import withPicker from './withPicker'; import { debounce } from 'lodash'; +import { getBackendSrv } from 'app/core/services/backend_srv'; -export interface IProps { - backendSrv: any; - isLoading: boolean; - toggleLoading: any; - handlePicked: (user) => void; +export interface Props { + onSelected: (user: User) => void; value?: string; className?: string; } +export interface State { + isLoading: boolean; +} + export interface User { id: number; label: string; @@ -20,13 +21,12 @@ export interface User { login: string; } -class UserPicker extends Component { +export class UserPicker extends Component { debouncedSearch: any; - backendSrv: any; constructor(props) { super(props); - this.state = {}; + this.state = { isLoading: false }; this.search = this.search.bind(this); this.debouncedSearch = debounce(this.search, 300, { @@ -36,29 +36,34 @@ class UserPicker extends Component { } search(query?: string) { - const { toggleLoading, backendSrv } = this.props; + const backendSrv = getBackendSrv(); - toggleLoading(true); - return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => { - const users = result.map(user => { + this.setState({ isLoading: true }); + + return backendSrv + .get(`/api/org/users?query=${query}&limit=10`) + .then(result => { return { - id: user.userId, - label: `${user.login} - ${user.email}`, - avatarUrl: user.avatarUrl, - login: user.login, + options: result.map(user => ({ + id: user.userId, + label: `${user.login} - ${user.email}`, + avatarUrl: user.avatarUrl, + login: user.login, + })), }; + }) + .finally(() => { + this.setState({ isLoading: false }); }); - toggleLoading(false); - return { options: users }; - }); } render() { - const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async; - const { isLoading, handlePicked, value, className } = this.props; + const { value, className } = this.props; + const { isLoading } = this.state; + return (
- { loadOptions={this.debouncedSearch} loadingPlaceholder="Loading..." noResultsText="No users found" - onChange={handlePicked} + onChange={this.props.onSelected} className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`} optionComponent={PickerOption} - placeholder="Choose" + placeholder="Select user" value={value} autosize={true} /> @@ -78,5 +83,3 @@ class UserPicker extends Component { ); } } - -export default withPicker(UserPicker); diff --git a/public/app/core/components/Picker/__snapshots__/PickerOption.jest.tsx.snap b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap similarity index 100% rename from public/app/core/components/Picker/__snapshots__/PickerOption.jest.tsx.snap rename to public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap diff --git a/public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap similarity index 100% rename from public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap rename to public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap diff --git a/public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap b/public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap similarity index 100% rename from public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap rename to public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap diff --git a/public/app/core/components/Picker/withPicker.tsx b/public/app/core/components/Picker/withPicker.tsx deleted file mode 100644 index 838ef927c30..00000000000 --- a/public/app/core/components/Picker/withPicker.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { Component } from 'react'; - -export interface IProps { - backendSrv: any; - handlePicked: (data) => void; - value?: string; - className?: string; -} - -export default function withPicker(WrappedComponent) { - return class WithPicker extends Component { - constructor(props) { - super(props); - this.toggleLoading = this.toggleLoading.bind(this); - - this.state = { - isLoading: false, - }; - } - - toggleLoading(isLoading) { - this.setState(prevState => { - return { - ...prevState, - isLoading: isLoading, - }; - }); - } - - render() { - return ; - } - }; -} diff --git a/public/app/core/components/Tooltip/Popover.jest.tsx b/public/app/core/components/Tooltip/Popover.test.tsx similarity index 100% rename from public/app/core/components/Tooltip/Popover.jest.tsx rename to public/app/core/components/Tooltip/Popover.test.tsx diff --git a/public/app/core/components/Tooltip/Tooltip.jest.tsx b/public/app/core/components/Tooltip/Tooltip.test.tsx similarity index 100% rename from public/app/core/components/Tooltip/Tooltip.jest.tsx rename to public/app/core/components/Tooltip/Tooltip.test.tsx diff --git a/public/app/core/components/Tooltip/__snapshots__/Popover.jest.tsx.snap b/public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap similarity index 100% rename from public/app/core/components/Tooltip/__snapshots__/Popover.jest.tsx.snap rename to public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap diff --git a/public/app/core/components/Tooltip/__snapshots__/Tooltip.jest.tsx.snap b/public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap similarity index 100% rename from public/app/core/components/Tooltip/__snapshots__/Tooltip.jest.tsx.snap rename to public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index fd2e32db3a7..bd6b6975006 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -8,7 +8,7 @@ import appEvents from 'app/core/app_events'; import Drop from 'tether-drop'; import { createStore } from 'app/stores/store'; import colors from 'app/core/utils/colors'; -import { BackendSrv } from 'app/core/services/backend_srv'; +import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; export class GrafanaCtrl { @@ -24,6 +24,8 @@ export class GrafanaCtrl { backendSrv: BackendSrv, datasourceSrv: DatasourceSrv ) { + // sets singleston instances for angular services so react components can access them + setBackendSrv(backendSrv); createStore({ backendSrv, datasourceSrv }); $scope.init = function() { diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts index a1d3c34ae5b..eac47b6e0a2 100644 --- a/public/app/core/components/help/help.ts +++ b/public/app/core/components/help/help.ts @@ -25,6 +25,7 @@ export class HelpCtrl { { keys: ['d', 'k'], description: 'Toggle kiosk mode (hides top nav)' }, { keys: ['d', 'E'], description: 'Expand all rows' }, { keys: ['d', 'C'], description: 'Collapse all rows' }, + { keys: ['d', 'a'], description: 'Toggle auto fit panels (experimental feature)' }, { keys: ['mod+o'], description: 'Toggle shared graph crosshair' }, ], 'Focused Panel': [ diff --git a/public/app/core/components/scroll/page_scroll.ts b/public/app/core/components/scroll/page_scroll.ts index e6db344a4d6..b6603f06175 100644 --- a/public/app/core/components/scroll/page_scroll.ts +++ b/public/app/core/components/scroll/page_scroll.ts @@ -29,11 +29,13 @@ export function pageScrollbar() { scope.$on('$routeChangeSuccess', () => { lastPos = 0; elem[0].scrollTop = 0; - elem[0].focus(); + // Focus page to enable scrolling by keyboard + elem[0].focus({ preventScroll: true }); }); elem[0].tabIndex = -1; - elem[0].focus(); + // Focus page to enable scrolling by keyboard + elem[0].focus({ preventScroll: true }); }, }; } diff --git a/public/app/core/components/team_picker.ts b/public/app/core/components/team_picker.ts deleted file mode 100644 index 228767a76c4..00000000000 --- a/public/app/core/components/team_picker.ts +++ /dev/null @@ -1,64 +0,0 @@ -import coreModule from 'app/core/core_module'; -import _ from 'lodash'; - -const template = ` - -`; -export class TeamPickerCtrl { - group: any; - teamPicked: any; - debouncedSearchGroups: any; - - /** @ngInject */ - constructor(private backendSrv) { - this.debouncedSearchGroups = _.debounce(this.searchGroups, 500, { - leading: true, - trailing: false, - }); - this.reset(); - } - - reset() { - this.group = { text: 'Choose', value: null }; - } - - searchGroups(query: string) { - return Promise.resolve( - this.backendSrv.get('/api/teams/search?perpage=10&page=1&query=' + query).then(result => { - return _.map(result.teams, ug => { - return { text: ug.name, value: ug }; - }); - }) - ); - } - - onChange(option) { - this.teamPicked({ $group: option.value }); - } -} - -export function teamPicker() { - return { - restrict: 'E', - template: template, - controller: TeamPickerCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: { - teamPicked: '&', - }, - link: function(scope, elem, attrs, ctrl) { - scope.$on('team-picker-reset', () => { - ctrl.reset(); - }); - }, - }; -} - -coreModule.directive('teamPicker', teamPicker); diff --git a/public/app/core/components/user_picker.ts b/public/app/core/components/user_picker.ts deleted file mode 100644 index 606ded09885..00000000000 --- a/public/app/core/components/user_picker.ts +++ /dev/null @@ -1,71 +0,0 @@ -import coreModule from 'app/core/core_module'; -import _ from 'lodash'; - -const template = ` - -`; -export class UserPickerCtrl { - user: any; - debouncedSearchUsers: any; - userPicked: any; - - /** @ngInject */ - constructor(private backendSrv) { - this.reset(); - this.debouncedSearchUsers = _.debounce(this.searchUsers, 500, { - leading: true, - trailing: false, - }); - } - - searchUsers(query: string) { - return Promise.resolve( - this.backendSrv.get('/api/users/search?perpage=10&page=1&query=' + query).then(result => { - return _.map(result.users, user => { - return { text: user.login + ' - ' + user.email, value: user }; - }); - }) - ); - } - - onChange(option) { - this.userPicked({ $user: option.value }); - } - - reset() { - this.user = { text: 'Choose', value: null }; - } -} - -export interface User { - id: number; - name: string; - login: string; - email: string; -} - -export function userPicker() { - return { - restrict: 'E', - template: template, - controller: UserPickerCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: { - userPicked: '&', - }, - link: function(scope, elem, attrs, ctrl) { - scope.$on('user-picker-reset', () => { - ctrl.reset(); - }); - }, - }; -} - -coreModule.directive('userPicker', userPicker); diff --git a/public/app/core/config.ts b/public/app/core/config.ts index e111d0d0e9f..e065ddb22fb 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,11 +1,18 @@ import _ from 'lodash'; -class Settings { +export interface BuildInfo { + version: string; + commit: string; + isEnterprise: boolean; + env: string; +} + +export class Settings { datasources: any; panels: any; appSubUrl: string; window_title_prefix: string; - buildInfo: any; + buildInfo: BuildInfo; new_panel_title: string; bootData: any; externalUserMngLinkUrl: string; @@ -32,7 +39,14 @@ class Settings { playlist_timespan: '1m', unsaved_changes_warning: true, appSubUrl: '', + buildInfo: { + version: 'v1.0', + commit: '1', + env: 'production', + isEnterprise: false, + }, }; + _.extend(this, defaults, options); } } diff --git a/public/app/core/core.ts b/public/app/core/core.ts index fb7021fe883..d6088283f3b 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -44,8 +44,6 @@ import { KeybindingSrv } from './services/keybindingSrv'; import { helpModal } from './components/help/help'; import { JsonExplorer } from './components/json_explorer/json_explorer'; import { NavModelSrv, NavModel } from './nav_model_srv'; -import { userPicker } from './components/user_picker'; -import { teamPicker } from './components/team_picker'; import { geminiScrollbar } from './components/scroll/scroll'; import { pageScrollbar } from './components/scroll/page_scroll'; import { gfPageDirective } from './components/gf_page'; @@ -83,8 +81,6 @@ export { JsonExplorer, NavModelSrv, NavModel, - userPicker, - teamPicker, geminiScrollbar, pageScrollbar, gfPageDirective, diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts new file mode 100644 index 00000000000..46e95a471ce --- /dev/null +++ b/public/app/core/logs_model.ts @@ -0,0 +1,29 @@ +export enum LogLevel { + crit = 'crit', + warn = 'warn', + err = 'error', + error = 'error', + info = 'info', + debug = 'debug', + trace = 'trace', +} + +export interface LogSearchMatch { + start: number; + length: number; + text?: string; +} + +export interface LogRow { + key: string; + entry: string; + logLevel: LogLevel; + timestamp: string; + timeFromNow: string; + timeLocal: string; + searchMatches?: LogSearchMatch[]; +} + +export interface LogsModel { + rows: LogRow[]; +} diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index d582b6a3b18..1aeeedef4dd 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -368,3 +368,17 @@ export class BackendSrv { } coreModule.service('backendSrv', BackendSrv); + +// +// Code below is to expore the service to react components +// + +let singletonInstance: BackendSrv; + +export function setBackendSrv(instance: BackendSrv) { + singletonInstance = instance; +} + +export function getBackendSrv(): BackendSrv { + return singletonInstance; +} diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index be8a0af7b7b..8959573e731 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -34,14 +34,10 @@ export class ContextSrv { constructor() { this.sidemenu = store.getBool('grafana.sidemenu', true); - if (!config.buildInfo) { - config.buildInfo = {}; - } if (!config.bootData) { config.bootData = { user: {}, settings: {} }; } - this.version = config.buildInfo.version; this.user = new User(); this.isSignedIn = this.user.isSignedIn; this.isGrafanaAdmin = this.user.isGrafanaAdmin; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index cbc7871fbbd..9d914a94a1c 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -191,7 +191,7 @@ export class KeybindingSrv { range, }; const exploreState = encodePathComponent(JSON.stringify(state)); - this.$location.url(`/explore/${exploreState}`); + this.$location.url(`/explore?state=${exploreState}`); } } }); @@ -259,6 +259,12 @@ export class KeybindingSrv { this.bind('d v', () => { appEvents.emit('toggle-view-mode'); }); + + //Autofit panels + this.bind('d a', () => { + // this has to be a full page reload + window.location.href = window.location.href + '&autofitpanels'; + }); } } diff --git a/public/app/core/specs/ColorPalette.jest.tsx b/public/app/core/specs/ColorPalette.test.tsx similarity index 100% rename from public/app/core/specs/ColorPalette.jest.tsx rename to public/app/core/specs/ColorPalette.test.tsx diff --git a/public/app/core/specs/PasswordStrength.jest.tsx b/public/app/core/specs/PasswordStrength.test.tsx similarity index 100% rename from public/app/core/specs/PasswordStrength.jest.tsx rename to public/app/core/specs/PasswordStrength.test.tsx diff --git a/public/app/core/specs/__snapshots__/ColorPalette.jest.tsx.snap b/public/app/core/specs/__snapshots__/ColorPalette.test.tsx.snap similarity index 100% rename from public/app/core/specs/__snapshots__/ColorPalette.jest.tsx.snap rename to public/app/core/specs/__snapshots__/ColorPalette.test.tsx.snap diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts new file mode 100644 index 00000000000..b19bd117766 --- /dev/null +++ b/public/app/core/specs/backend_srv.test.ts @@ -0,0 +1,25 @@ +import { BackendSrv } from 'app/core/services/backend_srv'; +jest.mock('app/core/store'); + +describe('backend_srv', function() { + let _httpBackend = options => { + if (options.url === 'gateway-error') { + return Promise.reject({ status: 502 }); + } + return Promise.resolve({}); + }; + + let _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {}); + + describe('when handling errors', () => { + it('should return the http status code', async () => { + try { + await _backendSrv.datasourceRequest({ + url: 'gateway-error', + }); + } catch (err) { + expect(err.status).toBe(502); + } + }); + }); +}); diff --git a/public/app/core/specs/backend_srv_specs.ts b/public/app/core/specs/backend_srv_specs.ts deleted file mode 100644 index 74b058b98c8..00000000000 --- a/public/app/core/specs/backend_srv_specs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common'; -import 'app/core/services/backend_srv'; - -describe('backend_srv', function() { - var _backendSrv; - var _httpBackend; - - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach( - angularMocks.inject(function($httpBackend, $http, backendSrv) { - _httpBackend = $httpBackend; - _backendSrv = backendSrv; - }) - ); - - describe('when handling errors', function() { - it('should return the http status code', function(done) { - _httpBackend.whenGET('gateway-error').respond(502); - _backendSrv - .datasourceRequest({ - url: 'gateway-error', - }) - .catch(function(err) { - expect(err.status).to.be(502); - done(); - }); - _httpBackend.flush(); - }); - }); -}); diff --git a/public/app/core/specs/datemath.jest.ts b/public/app/core/specs/datemath.test.ts similarity index 100% rename from public/app/core/specs/datemath.jest.ts rename to public/app/core/specs/datemath.test.ts diff --git a/public/app/core/specs/emitter.jest.ts b/public/app/core/specs/emitter.test.ts similarity index 100% rename from public/app/core/specs/emitter.jest.ts rename to public/app/core/specs/emitter.test.ts diff --git a/public/app/core/specs/file_export.jest.ts b/public/app/core/specs/file_export.test.ts similarity index 100% rename from public/app/core/specs/file_export.jest.ts rename to public/app/core/specs/file_export.test.ts diff --git a/public/app/core/specs/flatten.jest.ts b/public/app/core/specs/flatten.test.ts similarity index 100% rename from public/app/core/specs/flatten.jest.ts rename to public/app/core/specs/flatten.test.ts diff --git a/public/app/core/specs/kbn.jest.ts b/public/app/core/specs/kbn.test.ts similarity index 99% rename from public/app/core/specs/kbn.jest.ts rename to public/app/core/specs/kbn.test.ts index 68945068043..9c62990615c 100644 --- a/public/app/core/specs/kbn.jest.ts +++ b/public/app/core/specs/kbn.test.ts @@ -402,7 +402,7 @@ describe('duration', function() { describe('volume', function() { it('1000m3', function() { var str = kbn.valueFormats['m3'](1000, 1, null); - expect(str).toBe('1000.0 m3'); + expect(str).toBe('1000.0 m³'); }); }); diff --git a/public/app/core/specs/location_util.jest.ts b/public/app/core/specs/location_util.test.ts similarity index 100% rename from public/app/core/specs/location_util.jest.ts rename to public/app/core/specs/location_util.test.ts diff --git a/public/app/core/specs/manage_dashboards.jest.ts b/public/app/core/specs/manage_dashboards.test.ts similarity index 100% rename from public/app/core/specs/manage_dashboards.jest.ts rename to public/app/core/specs/manage_dashboards.test.ts diff --git a/public/app/core/specs/org_switcher.jest.ts b/public/app/core/specs/org_switcher.test.ts similarity index 100% rename from public/app/core/specs/org_switcher.jest.ts rename to public/app/core/specs/org_switcher.test.ts diff --git a/public/app/core/specs/rangeutil.jest.ts b/public/app/core/specs/rangeutil.test.ts similarity index 100% rename from public/app/core/specs/rangeutil.jest.ts rename to public/app/core/specs/rangeutil.test.ts diff --git a/public/app/core/specs/search.jest.ts b/public/app/core/specs/search.test.ts similarity index 100% rename from public/app/core/specs/search.jest.ts rename to public/app/core/specs/search.test.ts diff --git a/public/app/core/specs/search_results.jest.ts b/public/app/core/specs/search_results.test.ts similarity index 100% rename from public/app/core/specs/search_results.jest.ts rename to public/app/core/specs/search_results.test.ts diff --git a/public/app/core/specs/search_srv.jest.ts b/public/app/core/specs/search_srv.test.ts similarity index 100% rename from public/app/core/specs/search_srv.jest.ts rename to public/app/core/specs/search_srv.test.ts diff --git a/public/app/core/specs/store.jest.ts b/public/app/core/specs/store.test.ts similarity index 68% rename from public/app/core/specs/store.jest.ts rename to public/app/core/specs/store.test.ts index 0162960621d..ac02501f99e 100644 --- a/public/app/core/specs/store.jest.ts +++ b/public/app/core/specs/store.test.ts @@ -32,6 +32,18 @@ describe('store', () => { expect(store.getBool('key5', false)).toBe(true); }); + it('gets an object', () => { + expect(store.getObject('object1')).toBeUndefined(); + expect(store.getObject('object1', [])).toEqual([]); + store.setObject('object1', [1]); + expect(store.getObject('object1')).toEqual([1]); + }); + + it('sets an object', () => { + expect(store.setObject('object2', { a: 1 })).toBe(true); + expect(store.getObject('object2')).toEqual({ a: 1 }); + }); + it('key should be deleted', () => { store.set('key6', '123'); store.delete('key6'); diff --git a/public/app/core/specs/table_model.jest.ts b/public/app/core/specs/table_model.jest.ts deleted file mode 100644 index a2c1eb5e1af..00000000000 --- a/public/app/core/specs/table_model.jest.ts +++ /dev/null @@ -1,46 +0,0 @@ -import TableModel from 'app/core/table_model'; - -describe('when sorting table desc', () => { - var table; - var panel = { - sort: { col: 0, desc: true }, - }; - - beforeEach(() => { - table = new TableModel(); - table.columns = [{}, {}]; - table.rows = [[100, 12], [105, 10], [103, 11]]; - table.sort(panel.sort); - }); - - it('should sort by time', () => { - expect(table.rows[0][0]).toBe(105); - expect(table.rows[1][0]).toBe(103); - expect(table.rows[2][0]).toBe(100); - }); - - it('should mark column being sorted', () => { - expect(table.columns[0].sort).toBe(true); - expect(table.columns[0].desc).toBe(true); - }); -}); - -describe('when sorting table asc', () => { - var table; - var panel = { - sort: { col: 1, desc: false }, - }; - - beforeEach(() => { - table = new TableModel(); - table.columns = [{}, {}]; - table.rows = [[100, 11], [105, 15], [103, 10]]; - table.sort(panel.sort); - }); - - it('should sort by time', () => { - expect(table.rows[0][1]).toBe(10); - expect(table.rows[1][1]).toBe(11); - expect(table.rows[2][1]).toBe(15); - }); -}); diff --git a/public/app/core/specs/table_model.test.ts b/public/app/core/specs/table_model.test.ts new file mode 100644 index 00000000000..3d4c526cfea --- /dev/null +++ b/public/app/core/specs/table_model.test.ts @@ -0,0 +1,81 @@ +import TableModel from 'app/core/table_model'; + +describe('when sorting table desc', () => { + var table; + var panel = { + sort: { col: 0, desc: true }, + }; + + beforeEach(() => { + table = new TableModel(); + table.columns = [{}, {}]; + table.rows = [[100, 12], [105, 10], [103, 11]]; + table.sort(panel.sort); + }); + + it('should sort by time', () => { + expect(table.rows[0][0]).toBe(105); + expect(table.rows[1][0]).toBe(103); + expect(table.rows[2][0]).toBe(100); + }); + + it('should mark column being sorted', () => { + expect(table.columns[0].sort).toBe(true); + expect(table.columns[0].desc).toBe(true); + }); +}); + +describe('when sorting table asc', () => { + var table; + var panel = { + sort: { col: 1, desc: false }, + }; + + beforeEach(() => { + table = new TableModel(); + table.columns = [{}, {}]; + table.rows = [[100, 11], [105, 15], [103, 10]]; + table.sort(panel.sort); + }); + + it('should sort by time', () => { + expect(table.rows[0][1]).toBe(10); + expect(table.rows[1][1]).toBe(11); + expect(table.rows[2][1]).toBe(15); + }); +}); + +describe('when sorting with nulls', () => { + var table; + var values; + + beforeEach(() => { + table = new TableModel(); + table.columns = [{}, {}]; + table.rows = [[42, ''], [19, 'a'], [null, 'b'], [0, 'd'], [null, null], [2, 'c'], [0, null], [-8, '']]; + }); + + it('numbers with nulls at end with asc sort', () => { + table.sort({ col: 0, desc: false }); + values = table.rows.map(row => row[0]); + expect(values).toEqual([-8, 0, 0, 2, 19, 42, null, null]); + }); + + it('numbers with nulls at start with desc sort', () => { + table.sort({ col: 0, desc: true }); + values = table.rows.map(row => row[0]); + expect(values).toEqual([null, null, 42, 19, 2, 0, 0, -8]); + }); + + it('strings with nulls at end with asc sort', () => { + table.sort({ col: 1, desc: false }); + values = table.rows.map(row => row[1]); + expect(values).toEqual(['', '', 'a', 'b', 'c', 'd', null, null]); + }); + + it('strings with nulls at start with desc sort', () => { + table.sort({ col: 1, desc: true }); + values = table.rows.map(row => row[1]); + expect(values).toEqual([null, null, 'd', 'c', 'b', 'a', '', '']); + }); +}); diff --git a/public/app/core/specs/ticks.jest.ts b/public/app/core/specs/ticks.test.ts similarity index 100% rename from public/app/core/specs/ticks.jest.ts rename to public/app/core/specs/ticks.test.ts diff --git a/public/app/core/specs/time_series.jest.ts b/public/app/core/specs/time_series.test.ts similarity index 96% rename from public/app/core/specs/time_series.jest.ts rename to public/app/core/specs/time_series.test.ts index f5245476218..bf50d807e03 100644 --- a/public/app/core/specs/time_series.jest.ts +++ b/public/app/core/specs/time_series.test.ts @@ -119,6 +119,20 @@ describe('TimeSeries', function() { series.getFlotPairs('null'); expect(series.stats.avg).toBe(null); }); + + it('calculates timeStep', function() { + series = new TimeSeries({ + datapoints: [[null, 1], [null, 2], [null, 3]], + }); + series.getFlotPairs('null'); + expect(series.stats.timeStep).toBe(1); + + series = new TimeSeries({ + datapoints: [[0, 1530529290], [0, 1530529305], [0, 1530529320]], + }); + series.getFlotPairs('null'); + expect(series.stats.timeStep).toBe(15); + }); }); describe('When checking if ms resolution is needed', function() { diff --git a/public/app/core/specs/value_select_dropdown.jest.ts b/public/app/core/specs/value_select_dropdown.test.ts similarity index 100% rename from public/app/core/specs/value_select_dropdown.jest.ts rename to public/app/core/specs/value_select_dropdown.test.ts diff --git a/public/app/core/store.ts b/public/app/core/store.ts index b0714f49256..7cc969cf97f 100644 --- a/public/app/core/store.ts +++ b/public/app/core/store.ts @@ -14,6 +14,38 @@ export class Store { return window.localStorage[key] === 'true'; } + getObject(key: string, def?: any) { + let ret = def; + if (this.exists(key)) { + const json = window.localStorage[key]; + try { + ret = JSON.parse(json); + } catch (error) { + console.error(`Error parsing store object: ${key}. Returning default: ${def}. [${error}]`); + } + } + return ret; + } + + // Returns true when successfully stored + setObject(key: string, value: any): boolean { + let json; + try { + json = JSON.stringify(value); + } catch (error) { + console.error(`Could not stringify object: ${key}. [${error}]`); + return false; + } + try { + this.set(key, json); + } catch (error) { + // Likely hitting storage quota + console.error(`Could not save item in localStorage: ${key}. [${error}]`); + return false; + } + return true; + } + exists(key) { return window.localStorage[key] !== void 0; } diff --git a/public/app/core/table_model.ts b/public/app/core/table_model.ts index 5716aac2be6..0c85a0293dd 100644 --- a/public/app/core/table_model.ts +++ b/public/app/core/table_model.ts @@ -1,5 +1,15 @@ +interface Column { + text: string; + title?: string; + type?: string; + sort?: boolean; + desc?: boolean; + filterable?: boolean; + unit?: string; +} + export default class TableModel { - columns: any[]; + columns: Column[]; rows: any[]; type: string; columnMap: any; @@ -19,23 +29,16 @@ export default class TableModel { this.rows.sort(function(a, b) { a = a[options.col]; b = b[options.col]; - if (a < b) { - return -1; - } - if (a > b) { - return 1; - } - return 0; + // Sort null or undefined seperately from comparable values + return +(a == null) - +(b == null) || +(a > b) || -(a < b); }); - this.columns[options.col].sort = true; - if (options.desc) { this.rows.reverse(); - this.columns[options.col].desc = true; - } else { - this.columns[options.col].desc = false; } + + this.columns[options.col].sort = true; + this.columns[options.col].desc = options.desc; } addColumn(col) { diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index 59729ebc312..f4d0943d52f 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -76,6 +76,7 @@ export default class TimeSeries { valueFormater: any; stats: any; legend: boolean; + hideTooltip: boolean; allIsNull: boolean; allIsZero: boolean; decimals: number; @@ -181,6 +182,9 @@ export default class TimeSeries { if (override.legend !== void 0) { this.legend = override.legend; } + if (override.hideTooltip !== void 0) { + this.hideTooltip = override.hideTooltip; + } if (override.yaxis !== void 0) { this.yaxis = override.yaxis; diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 4302e62e3e0..c2764670b95 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -449,6 +449,7 @@ kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr'); kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr'); kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk'); kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF'); +kbn.valueFormats.currencyPLN = kbn.formatBuilders.currency('zł'); // Data (Binary) kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b'); @@ -499,7 +500,7 @@ kbn.valueFormats.watt = kbn.formatBuilders.decimalSIPrefix('W'); kbn.valueFormats.kwatt = kbn.formatBuilders.decimalSIPrefix('W', 1); kbn.valueFormats.mwatt = kbn.formatBuilders.decimalSIPrefix('W', -1); kbn.valueFormats.kwattm = kbn.formatBuilders.decimalSIPrefix('W/Min', 1); -kbn.valueFormats.Wm2 = kbn.formatBuilders.fixedUnit('W/m2'); +kbn.valueFormats.Wm2 = kbn.formatBuilders.fixedUnit('W/m²'); kbn.valueFormats.voltamp = kbn.formatBuilders.decimalSIPrefix('VA'); kbn.valueFormats.kvoltamp = kbn.formatBuilders.decimalSIPrefix('VA', 1); kbn.valueFormats.voltampreact = kbn.formatBuilders.decimalSIPrefix('var'); @@ -571,9 +572,9 @@ kbn.valueFormats.accG = kbn.formatBuilders.fixedUnit('g'); // Volume kbn.valueFormats.litre = kbn.formatBuilders.decimalSIPrefix('L'); kbn.valueFormats.mlitre = kbn.formatBuilders.decimalSIPrefix('L', -1); -kbn.valueFormats.m3 = kbn.formatBuilders.fixedUnit('m3'); -kbn.valueFormats.Nm3 = kbn.formatBuilders.fixedUnit('Nm3'); -kbn.valueFormats.dm3 = kbn.formatBuilders.fixedUnit('dm3'); +kbn.valueFormats.m3 = kbn.formatBuilders.fixedUnit('m³'); +kbn.valueFormats.Nm3 = kbn.formatBuilders.fixedUnit('Nm³'); +kbn.valueFormats.dm3 = kbn.formatBuilders.fixedUnit('dm³'); kbn.valueFormats.gallons = kbn.formatBuilders.fixedUnit('gal'); // Flow @@ -604,14 +605,14 @@ kbn.valueFormats.radsvh = kbn.formatBuilders.decimalSIPrefix('Sv/h'); // Concentration kbn.valueFormats.ppm = kbn.formatBuilders.fixedUnit('ppm'); kbn.valueFormats.conppb = kbn.formatBuilders.fixedUnit('ppb'); -kbn.valueFormats.conngm3 = kbn.formatBuilders.fixedUnit('ng/m3'); -kbn.valueFormats.conngNm3 = kbn.formatBuilders.fixedUnit('ng/Nm3'); -kbn.valueFormats.conμgm3 = kbn.formatBuilders.fixedUnit('μg/m3'); -kbn.valueFormats.conμgNm3 = kbn.formatBuilders.fixedUnit('μg/Nm3'); -kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m3'); -kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm3'); -kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m3'); -kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm3'); +kbn.valueFormats.conngm3 = kbn.formatBuilders.fixedUnit('ng/m³'); +kbn.valueFormats.conngNm3 = kbn.formatBuilders.fixedUnit('ng/Nm³'); +kbn.valueFormats.conμgm3 = kbn.formatBuilders.fixedUnit('μg/m³'); +kbn.valueFormats.conμgNm3 = kbn.formatBuilders.fixedUnit('μg/Nm³'); +kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m³'); +kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm³'); +kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m³'); +kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm³'); // Time kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz'); @@ -880,6 +881,7 @@ kbn.getUnitFormats = function() { { text: 'Swedish Krona (kr)', value: 'currencySEK' }, { text: 'Czech koruna (czk)', value: 'currencyCZK' }, { text: 'Swiss franc (CHF)', value: 'currencyCHF' }, + { text: 'Polish Złoty (PLN)', value: 'currencyPLN' }, ], }, { @@ -957,7 +959,7 @@ kbn.getUnitFormats = function() { text: 'throughput', submenu: [ { text: 'ops/sec (ops)', value: 'ops' }, - { text: 'requets/sec (rps)', value: 'reqps' }, + { text: 'requests/sec (rps)', value: 'reqps' }, { text: 'reads/sec (rps)', value: 'rps' }, { text: 'writes/sec (wps)', value: 'wps' }, { text: 'I/O ops/sec (iops)', value: 'iops' }, @@ -1019,7 +1021,7 @@ kbn.getUnitFormats = function() { { text: 'Watt (W)', value: 'watt' }, { text: 'Kilowatt (kW)', value: 'kwatt' }, { text: 'Milliwatt (mW)', value: 'mwatt' }, - { text: 'Watt per square metre (W/m2)', value: 'Wm2' }, + { text: 'Watt per square metre (W/m²)', value: 'Wm2' }, { text: 'Volt-ampere (VA)', value: 'voltamp' }, { text: 'Kilovolt-ampere (kVA)', value: 'kvoltamp' }, { text: 'Volt-ampere reactive (var)', value: 'voltampreact' }, @@ -1116,14 +1118,14 @@ kbn.getUnitFormats = function() { submenu: [ { text: 'parts-per-million (ppm)', value: 'ppm' }, { text: 'parts-per-billion (ppb)', value: 'conppb' }, - { text: 'nanogram per cubic metre (ng/m3)', value: 'conngm3' }, - { text: 'nanogram per normal cubic metre (ng/Nm3)', value: 'conngNm3' }, - { text: 'microgram per cubic metre (μg/m3)', value: 'conμgm3' }, - { text: 'microgram per normal cubic metre (μg/Nm3)', value: 'conμgNm3' }, - { text: 'milligram per cubic metre (mg/m3)', value: 'conmgm3' }, - { text: 'milligram per normal cubic metre (mg/Nm3)', value: 'conmgNm3' }, - { text: 'gram per cubic metre (g/m3)', value: 'congm3' }, - { text: 'gram per normal cubic metre (g/Nm3)', value: 'congNm3' }, + { text: 'nanogram per cubic metre (ng/m³)', value: 'conngm3' }, + { text: 'nanogram per normal cubic metre (ng/Nm³)', value: 'conngNm3' }, + { text: 'microgram per cubic metre (μg/m³)', value: 'conμgm3' }, + { text: 'microgram per normal cubic metre (μg/Nm³)', value: 'conμgNm3' }, + { text: 'milligram per cubic metre (mg/m³)', value: 'conmgm3' }, + { text: 'milligram per normal cubic metre (mg/Nm³)', value: 'conmgNm3' }, + { text: 'gram per cubic metre (g/m³)', value: 'congm3' }, + { text: 'gram per normal cubic metre (g/Nm³)', value: 'congNm3' }, ], }, ]; diff --git a/public/app/features/alerting/specs/alert_tab_specs.ts b/public/app/features/alerting/specs/alert_tab_specs.ts deleted file mode 100644 index 4a4de34fe6c..00000000000 --- a/public/app/features/alerting/specs/alert_tab_specs.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, it, expect } from 'test/lib/common'; - -import { AlertTabCtrl } from '../alert_tab_ctrl'; - -describe('AlertTabCtrl', () => { - var $scope = { - ctrl: {}, - }; - - describe('with null parameters', () => { - it('can be created', () => { - var alertTab = new AlertTabCtrl($scope, null, null, null, null, null); - - expect(alertTab).to.not.be(null); - }); - }); -}); diff --git a/public/app/features/alerting/specs/threshold_mapper.jest.ts b/public/app/features/alerting/specs/threshold_mapper.test.ts similarity index 100% rename from public/app/features/alerting/specs/threshold_mapper.jest.ts rename to public/app/features/alerting/specs/threshold_mapper.test.ts diff --git a/public/app/features/annotations/specs/annotations_srv.jest.ts b/public/app/features/annotations/specs/annotations_srv.test.ts similarity index 100% rename from public/app/features/annotations/specs/annotations_srv.jest.ts rename to public/app/features/annotations/specs/annotations_srv.test.ts diff --git a/public/app/features/annotations/specs/annotations_srv_specs.jest.ts b/public/app/features/annotations/specs/annotations_srv_specs.test.ts similarity index 100% rename from public/app/features/annotations/specs/annotations_srv_specs.jest.ts rename to public/app/features/annotations/specs/annotations_srv_specs.test.ts diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index 94d0b18f157..c6bb6492172 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -62,6 +62,8 @@ export class DashboardCtrl implements PanelContainer { .finally(() => { this.dashboard = dashboard; this.dashboard.processRepeats(); + this.dashboard.updateSubmenuVisibility(); + this.dashboard.autoFitPanels(window.innerHeight); this.unsavedChangesSrv.init(dashboard, this.$scope); @@ -70,8 +72,6 @@ export class DashboardCtrl implements PanelContainer { this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope); this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); - - this.dashboard.updateSubmenuVisibility(); this.setWindowTitleAndTheme(); this.$scope.appEvent('dashboard-initialized', dashboard); diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 976e4213920..92392fc80e8 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -1,7 +1,7 @@ import moment from 'moment'; import _ from 'lodash'; -import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL } from 'app/core/constants'; +import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { DEFAULT_ANNOTATION_COLOR } from 'app/core/utils/colors'; import { Emitter } from 'app/core/utils/emitter'; import { contextSrv } from 'app/core/services/context_srv'; @@ -830,4 +830,32 @@ export class DashboardModel { return !_.isEqual(updated, this.originalTemplating); } + + autoFitPanels(viewHeight: number) { + if (!this.meta.autofitpanels) { + return; + } + + const currentGridHeight = Math.max( + ...this.panels.map(panel => { + return panel.gridPos.h + panel.gridPos.y; + }) + ); + + // Consider navbar and submenu controls, padding and margin + let visibleHeight = window.innerHeight - 55 - 20; + + // Remove submenu if visible + if (this.meta.submenuEnabled) { + visibleHeight -= 50; + } + + const visibleGridHeight = Math.floor(visibleHeight / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); + const scaleFactor = currentGridHeight / visibleGridHeight; + + this.panels.forEach((panel, i) => { + panel.gridPos.y = Math.round(panel.gridPos.y / scaleFactor) || 1; + panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1; + }); + } } diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/dashnav/dashnav.html index 269d4b0bada..6ec272b5ca4 100644 --- a/public/app/features/dashboard/dashnav/dashnav.html +++ b/public/app/features/dashboard/dashnav/dashnav.html @@ -3,7 +3,7 @@ diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts index 28338c29d33..352b29d27a0 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -104,10 +104,7 @@ export class FolderPickerCtrl { appEvents.emit('alert-success', ['Folder Created', 'OK']); this.closeCreateFolder(); - this.folder = { - text: result.title, - value: result.id, - }; + this.folder = { text: result.title, value: result.id }; this.onFolderChange(this.folder); }); } @@ -149,17 +146,14 @@ export class FolderPickerCtrl { folder = result.length > 0 ? result[0] : resetFolder; } } - this.folder = folder; - this.onFolderLoad(); - }); - } - private onFolderLoad() { - if (this.onLoad) { - this.onLoad({ - $folder: { id: this.folder.value, title: this.folder.text }, - }); - } + this.folder = folder; + + // if this is not the same as our initial value notify parent + if (this.folder.id !== this.initialFolderId) { + this.onChange({ $folder: { id: this.folder.value, title: this.folder.text } }); + } + }); } } @@ -176,7 +170,6 @@ export function folderPicker() { labelClass: '@', rootName: '@', onChange: '&', - onLoad: '&', onCreateFolder: '&', enterFolderCreation: '&', exitFolderCreation: '&', diff --git a/public/app/features/dashboard/shareModalCtrl.ts b/public/app/features/dashboard/shareModalCtrl.ts index 985c20f03b2..c32c2a79190 100644 --- a/public/app/features/dashboard/shareModalCtrl.ts +++ b/public/app/features/dashboard/shareModalCtrl.ts @@ -2,120 +2,118 @@ import angular from 'angular'; import config from 'app/core/config'; import moment from 'moment'; -export class ShareModalCtrl { - /** @ngInject */ - constructor($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) { - $scope.options = { - forCurrent: true, - includeTemplateVars: true, - theme: 'current', - }; - $scope.editor = { index: $scope.tabIndex || 0 }; +/** @ngInject */ +export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) { + $scope.options = { + forCurrent: true, + includeTemplateVars: true, + theme: 'current', + }; + $scope.editor = { index: $scope.tabIndex || 0 }; - $scope.init = function() { - $scope.modeSharePanel = $scope.panel ? true : false; + $scope.init = function() { + $scope.modeSharePanel = $scope.panel ? true : false; - $scope.tabs = [{ title: 'Link', src: 'shareLink.html' }]; + $scope.tabs = [{ title: 'Link', src: 'shareLink.html' }]; - if ($scope.modeSharePanel) { - $scope.modalTitle = 'Share Panel'; - $scope.tabs.push({ title: 'Embed', src: 'shareEmbed.html' }); - } else { - $scope.modalTitle = 'Share'; - } + if ($scope.modeSharePanel) { + $scope.modalTitle = 'Share Panel'; + $scope.tabs.push({ title: 'Embed', src: 'shareEmbed.html' }); + } else { + $scope.modalTitle = 'Share'; + } - if (!$scope.dashboard.meta.isSnapshot) { - $scope.tabs.push({ title: 'Snapshot', src: 'shareSnapshot.html' }); - } + if (!$scope.dashboard.meta.isSnapshot) { + $scope.tabs.push({ title: 'Snapshot', src: 'shareSnapshot.html' }); + } - if (!$scope.dashboard.meta.isSnapshot && !$scope.modeSharePanel) { - $scope.tabs.push({ title: 'Export', src: 'shareExport.html' }); - } + if (!$scope.dashboard.meta.isSnapshot && !$scope.modeSharePanel) { + $scope.tabs.push({ title: 'Export', src: 'shareExport.html' }); + } - $scope.buildUrl(); - }; + $scope.buildUrl(); + }; - $scope.buildUrl = function() { - var baseUrl = $location.absUrl(); - var queryStart = baseUrl.indexOf('?'); + $scope.buildUrl = function() { + var baseUrl = $location.absUrl(); + var queryStart = baseUrl.indexOf('?'); - if (queryStart !== -1) { - baseUrl = baseUrl.substring(0, queryStart); - } + if (queryStart !== -1) { + baseUrl = baseUrl.substring(0, queryStart); + } - var params = angular.copy($location.search()); + var params = angular.copy($location.search()); - var range = timeSrv.timeRange(); - params.from = range.from.valueOf(); - params.to = range.to.valueOf(); - params.orgId = config.bootData.user.orgId; + var range = timeSrv.timeRange(); + params.from = range.from.valueOf(); + params.to = range.to.valueOf(); + params.orgId = config.bootData.user.orgId; - if ($scope.options.includeTemplateVars) { - templateSrv.fillVariableValuesForUrl(params); - } + if ($scope.options.includeTemplateVars) { + templateSrv.fillVariableValuesForUrl(params); + } - if (!$scope.options.forCurrent) { - delete params.from; - delete params.to; - } + if (!$scope.options.forCurrent) { + delete params.from; + delete params.to; + } - if ($scope.options.theme !== 'current') { - params.theme = $scope.options.theme; - } + if ($scope.options.theme !== 'current') { + params.theme = $scope.options.theme; + } - if ($scope.modeSharePanel) { - params.panelId = $scope.panel.id; - params.fullscreen = true; - } else { - delete params.panelId; - delete params.fullscreen; - } - - $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params); - - var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); - soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); + if ($scope.modeSharePanel) { + params.panelId = $scope.panel.id; + params.fullscreen = true; + } else { + delete params.panelId; delete params.fullscreen; - delete params.edit; - soloUrl = linkSrv.addParamsToUrl(soloUrl, params); + } - $scope.iframeHtml = ''; + $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params); - $scope.imageUrl = soloUrl.replace( - config.appSubUrl + '/dashboard-solo/', - config.appSubUrl + '/render/dashboard-solo/' - ); - $scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); - $scope.imageUrl += '&width=1000&height=500' + $scope.getLocalTimeZone(); - }; + var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); + soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); + delete params.fullscreen; + delete params.edit; + soloUrl = linkSrv.addParamsToUrl(soloUrl, params); - // This function will try to return the proper full name of the local timezone - // Chrome does not handle the timezone offset (but phantomjs does) - $scope.getLocalTimeZone = function() { - let utcOffset = '&tz=UTC' + encodeURIComponent(moment().format('Z')); + $scope.iframeHtml = ''; - // Older browser does not the internationalization API - if (!(window).Intl) { - return utcOffset; - } + $scope.imageUrl = soloUrl.replace( + config.appSubUrl + '/dashboard-solo/', + config.appSubUrl + '/render/dashboard-solo/' + ); + $scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); + $scope.imageUrl += '&width=1000&height=500' + $scope.getLocalTimeZone(); + }; - const dateFormat = (window).Intl.DateTimeFormat(); - if (!dateFormat.resolvedOptions) { - return utcOffset; - } + // This function will try to return the proper full name of the local timezone + // Chrome does not handle the timezone offset (but phantomjs does) + $scope.getLocalTimeZone = function() { + let utcOffset = '&tz=UTC' + encodeURIComponent(moment().format('Z')); - const options = dateFormat.resolvedOptions(); - if (!options.timeZone) { - return utcOffset; - } + // Older browser does not the internationalization API + if (!(window).Intl) { + return utcOffset; + } - return '&tz=' + encodeURIComponent(options.timeZone); - }; + const dateFormat = (window).Intl.DateTimeFormat(); + if (!dateFormat.resolvedOptions) { + return utcOffset; + } - $scope.getShareUrl = function() { - return $scope.shareUrl; - }; - } + const options = dateFormat.resolvedOptions(); + if (!options.timeZone) { + return utcOffset; + } + + return '&tz=' + encodeURIComponent(options.timeZone); + }; + + $scope.getShareUrl = function() { + return $scope.shareUrl; + }; } angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl); diff --git a/public/app/features/dashboard/specs/AddPanelPanel.jest.tsx b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx similarity index 100% rename from public/app/features/dashboard/specs/AddPanelPanel.jest.tsx rename to public/app/features/dashboard/specs/AddPanelPanel.test.tsx diff --git a/public/app/features/dashboard/specs/DashboardRow.jest.tsx b/public/app/features/dashboard/specs/DashboardRow.test.tsx similarity index 100% rename from public/app/features/dashboard/specs/DashboardRow.jest.tsx rename to public/app/features/dashboard/specs/DashboardRow.test.tsx diff --git a/public/app/features/dashboard/specs/change_tracker.jest.ts b/public/app/features/dashboard/specs/change_tracker.test.ts similarity index 100% rename from public/app/features/dashboard/specs/change_tracker.jest.ts rename to public/app/features/dashboard/specs/change_tracker.test.ts diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts b/public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts similarity index 100% rename from public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts rename to public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts diff --git a/public/app/features/dashboard/specs/dashboard_migration.jest.ts b/public/app/features/dashboard/specs/dashboard_migration.test.ts similarity index 100% rename from public/app/features/dashboard/specs/dashboard_migration.jest.ts rename to public/app/features/dashboard/specs/dashboard_migration.test.ts diff --git a/public/app/features/dashboard/specs/dashboard_model.jest.ts b/public/app/features/dashboard/specs/dashboard_model.test.ts similarity index 100% rename from public/app/features/dashboard/specs/dashboard_model.jest.ts rename to public/app/features/dashboard/specs/dashboard_model.test.ts diff --git a/public/app/features/dashboard/specs/dashboard_srv_specs.ts b/public/app/features/dashboard/specs/dashboard_srv_specs.ts deleted file mode 100644 index 0faa7531652..00000000000 --- a/public/app/features/dashboard/specs/dashboard_srv_specs.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, beforeEach, expect } from 'test/lib/common'; - -import { DashboardSrv } from '../dashboard_srv'; - -describe('dashboardSrv', function() { - var _dashboardSrv; - - beforeEach(() => { - _dashboardSrv = new DashboardSrv({}, {}, {}); - }); - - it('should do something', () => { - expect(_dashboardSrv).not.to.be(null); - }); -}); diff --git a/public/app/features/dashboard/specs/exporter.jest.ts b/public/app/features/dashboard/specs/exporter.test.ts similarity index 99% rename from public/app/features/dashboard/specs/exporter.jest.ts rename to public/app/features/dashboard/specs/exporter.test.ts index aa574a4b85a..c7727a4af4d 100644 --- a/public/app/features/dashboard/specs/exporter.jest.ts +++ b/public/app/features/dashboard/specs/exporter.test.ts @@ -86,9 +86,7 @@ describe('given dashboard with repeated panels', () => { ], }; - config.buildInfo = { - version: '3.0.2', - }; + config.buildInfo.version = '3.0.2'; //Stubs test function calls var datasourceSrvStub = { get: jest.fn(arg => getStub(arg)) }; diff --git a/public/app/features/dashboard/specs/history_ctrl.jest.ts b/public/app/features/dashboard/specs/history_ctrl.test.ts similarity index 100% rename from public/app/features/dashboard/specs/history_ctrl.jest.ts rename to public/app/features/dashboard/specs/history_ctrl.test.ts diff --git a/public/app/features/dashboard/specs/history_srv.jest.ts b/public/app/features/dashboard/specs/history_srv.test.ts similarity index 100% rename from public/app/features/dashboard/specs/history_srv.jest.ts rename to public/app/features/dashboard/specs/history_srv.test.ts diff --git a/public/app/features/dashboard/specs/repeat.jest.ts b/public/app/features/dashboard/specs/repeat.test.ts similarity index 100% rename from public/app/features/dashboard/specs/repeat.jest.ts rename to public/app/features/dashboard/specs/repeat.test.ts diff --git a/public/app/features/dashboard/specs/save_as_modal.jest.ts b/public/app/features/dashboard/specs/save_as_modal.test.ts similarity index 100% rename from public/app/features/dashboard/specs/save_as_modal.jest.ts rename to public/app/features/dashboard/specs/save_as_modal.test.ts diff --git a/public/app/features/dashboard/specs/save_modal.jest.ts b/public/app/features/dashboard/specs/save_modal.test.ts similarity index 100% rename from public/app/features/dashboard/specs/save_modal.jest.ts rename to public/app/features/dashboard/specs/save_modal.test.ts diff --git a/public/app/features/dashboard/specs/save_provisioned_modal.jest.ts b/public/app/features/dashboard/specs/save_provisioned_modal.test.ts similarity index 100% rename from public/app/features/dashboard/specs/save_provisioned_modal.jest.ts rename to public/app/features/dashboard/specs/save_provisioned_modal.test.ts diff --git a/public/app/features/dashboard/specs/share_modal_ctrl.jest.ts b/public/app/features/dashboard/specs/share_modal_ctrl.jest.ts new file mode 100644 index 00000000000..35261256566 --- /dev/null +++ b/public/app/features/dashboard/specs/share_modal_ctrl.jest.ts @@ -0,0 +1,150 @@ +import '../shareModalCtrl'; +import { ShareModalCtrl } from '../shareModalCtrl'; +import config from 'app/core/config'; +import { LinkSrv } from 'app/features/panellinks/link_srv'; + +describe('ShareModalCtrl', () => { + var ctx = { + timeSrv: { + timeRange: () => { + return { from: new Date(1000), to: new Date(2000) }; + }, + }, + $location: { + absUrl: () => 'http://server/#!/test', + search: () => { + return { from: '', to: '' }; + }, + }, + scope: { + dashboard: { + meta: { + isSnapshot: true, + }, + }, + }, + templateSrv: { + fillVariableValuesForUrl: () => {}, + }, + }; + + (window).Intl.DateTimeFormat = () => { + return { + resolvedOptions: () => { + return { timeZone: 'UTC' }; + }, + }; + }; + + beforeEach(() => { + config.bootData = { + user: { + orgId: 1, + }, + }; + + ctx.ctrl = new ShareModalCtrl( + ctx.scope, + {}, + ctx.$location, + {}, + ctx.timeSrv, + ctx.templateSrv, + new LinkSrv({}, ctx.stimeSrv) + ); + }); + + describe('shareUrl with current time range and panel', () => { + it('should generate share url absolute time', () => { + ctx.scope.panel = { id: 22 }; + + ctx.scope.init(); + expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen'); + }); + + it('should generate render url', () => { + ctx.$location.absUrl = () => 'http://dashboards.grafana.com/d/abcdefghi/my-dash'; + + ctx.scope.panel = { id: 22 }; + + ctx.scope.init(); + var base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; + var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(ctx.scope.imageUrl).toContain(base + params); + }); + + it('should generate render url for scripted dashboard', () => { + ctx.$location.absUrl = () => 'http://dashboards.grafana.com/dashboard/script/my-dash.js'; + + ctx.scope.panel = { id: 22 }; + + ctx.scope.init(); + var base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; + var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(ctx.scope.imageUrl).toContain(base + params); + }); + + it('should remove panel id when no panel in scope', () => { + ctx.$location.absUrl = () => 'http://server/#!/test'; + ctx.scope.options.forCurrent = true; + ctx.scope.panel = null; + + ctx.scope.init(); + expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1'); + }); + + it('should add theme when specified', () => { + ctx.scope.options.theme = 'light'; + ctx.scope.panel = null; + + ctx.scope.init(); + expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); + }); + + it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', () => { + ctx.$location.search = () => { + return { fullscreen: true, edit: true }; + }; + ctx.$location.absUrl = () => 'http://server/#!/test?fullscreen&edit'; + ctx.scope.modeSharePanel = true; + ctx.scope.panel = { id: 1 }; + + ctx.scope.buildUrl(); + + expect(ctx.scope.shareUrl).toContain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1'); + expect(ctx.scope.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); + + it('should remove edit from image url when is first param in querystring and modeSharePanel is true', () => { + ctx.$location.search = () => { + return { edit: true, fullscreen: true }; + }; + ctx.$location.absUrl = () => 'http://server/#!/test?edit&fullscreen'; + ctx.scope.modeSharePanel = true; + ctx.scope.panel = { id: 1 }; + + ctx.scope.buildUrl(); + + expect(ctx.scope.shareUrl).toContain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1'); + expect(ctx.scope.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); + + it('should include template variables in url', () => { + ctx.$location.search = () => { + return {}; + }; + ctx.$location.absUrl = () => 'http://server/#!/test'; + ctx.scope.options.includeTemplateVars = true; + + ctx.templateSrv.fillVariableValuesForUrl = function(params) { + params['var-app'] = 'mupp'; + params['var-server'] = 'srv-01'; + }; + + ctx.scope.buildUrl(); + expect(ctx.scope.shareUrl).toContain( + 'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01' + ); + }); + }); +}); diff --git a/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts b/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts deleted file mode 100644 index fc70a54a41c..00000000000 --- a/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common'; -import helpers from 'test/specs/helpers'; -import '../shareModalCtrl'; -import config from 'app/core/config'; -import 'app/features/panellinks/link_srv'; - -describe('ShareModalCtrl', function() { - var ctx = new helpers.ControllerTestContext(); - - function setTime(range) { - ctx.timeSrv.timeRange = sinon.stub().returns(range); - } - - beforeEach(function() { - config.bootData = { - user: { - orgId: 1, - }, - }; - }); - - setTime({ from: new Date(1000), to: new Date(2000) }); - - beforeEach(angularMocks.module('grafana.controllers')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach( - angularMocks.module(function($compileProvider) { - $compileProvider.preAssignBindingsEnabled(true); - }) - ); - - beforeEach(ctx.providePhase()); - - beforeEach(ctx.createControllerPhase('ShareModalCtrl')); - - describe('shareUrl with current time range and panel', function() { - it('should generate share url absolute time', function() { - ctx.$location.path('/test'); - ctx.scope.panel = { id: 22 }; - - ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen'); - }); - - it('should generate render url', function() { - ctx.$location.$$absUrl = 'http://dashboards.grafana.com/d/abcdefghi/my-dash'; - - ctx.scope.panel = { id: 22 }; - - ctx.scope.init(); - var base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; - var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; - expect(ctx.scope.imageUrl).to.contain(base + params); - }); - - it('should generate render url for scripted dashboard', function() { - ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/script/my-dash.js'; - - ctx.scope.panel = { id: 22 }; - - ctx.scope.init(); - var base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; - var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; - expect(ctx.scope.imageUrl).to.contain(base + params); - }); - - it('should remove panel id when no panel in scope', function() { - ctx.$location.path('/test'); - ctx.scope.options.forCurrent = true; - ctx.scope.panel = null; - - ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1'); - }); - - it('should add theme when specified', function() { - ctx.$location.path('/test'); - ctx.scope.options.theme = 'light'; - ctx.scope.panel = null; - - ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); - }); - - it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', function() { - ctx.$location.url('/test?fullscreen&edit'); - ctx.scope.modeSharePanel = true; - ctx.scope.panel = { id: 1 }; - - ctx.scope.buildUrl(); - - expect(ctx.scope.shareUrl).to.contain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1'); - expect(ctx.scope.imageUrl).to.contain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); - }); - - it('should remove edit from image url when is first param in querystring and modeSharePanel is true', function() { - ctx.$location.url('/test?edit&fullscreen'); - ctx.scope.modeSharePanel = true; - ctx.scope.panel = { id: 1 }; - - ctx.scope.buildUrl(); - - expect(ctx.scope.shareUrl).to.contain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1'); - expect(ctx.scope.imageUrl).to.contain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); - }); - - it('should include template variables in url', function() { - ctx.$location.path('/test'); - ctx.scope.options.includeTemplateVars = true; - - ctx.templateSrv.fillVariableValuesForUrl = function(params) { - params['var-app'] = 'mupp'; - params['var-server'] = 'srv-01'; - }; - - ctx.scope.buildUrl(); - expect(ctx.scope.shareUrl).to.be( - 'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01' - ); - }); - }); -}); diff --git a/public/app/features/dashboard/specs/time_srv.jest.ts b/public/app/features/dashboard/specs/time_srv.test.ts similarity index 100% rename from public/app/features/dashboard/specs/time_srv.jest.ts rename to public/app/features/dashboard/specs/time_srv.test.ts diff --git a/public/app/features/dashboard/specs/viewstate_srv.jest.ts b/public/app/features/dashboard/specs/viewstate_srv.test.ts similarity index 100% rename from public/app/features/dashboard/specs/viewstate_srv.jest.ts rename to public/app/features/dashboard/specs/viewstate_srv.test.ts diff --git a/public/app/features/dashlinks/module.ts b/public/app/features/dashlinks/module.ts index f9482049c40..4d80f3632e6 100644 --- a/public/app/features/dashlinks/module.ts +++ b/public/app/features/dashlinks/module.ts @@ -41,20 +41,20 @@ function dashLink($compile, $sanitize, linkSrv) { elem.html(template); $compile(elem.contents())(scope); - var anchor = elem.find('a'); - var icon = elem.find('i'); - var span = elem.find('span'); - function update() { var linkInfo = linkSrv.getAnchorInfo(link); + + const anchor = elem.find('a'); + const span = elem.find('span'); span.text(linkInfo.title); + if (!link.asDropdown) { anchor.attr('href', linkInfo.href); sanitizeAnchor(); } - elem.find('a').attr('data-placement', 'bottom'); + anchor.attr('data-placement', 'bottom'); // tooltip - elem.find('a').tooltip({ + anchor.tooltip({ title: $sanitize(scope.link.tooltip), html: true, container: 'body', @@ -62,12 +62,13 @@ function dashLink($compile, $sanitize, linkSrv) { } function sanitizeAnchor() { + const anchor = elem.find('a'); const anchorSanitized = $sanitize(anchor.parent().html()); anchor.parent().html(anchorSanitized); } - icon.attr('class', 'fa fa-fw ' + scope.link.icon); - anchor.attr('target', scope.link.target); + elem.find('i').attr('class', 'fa fa-fw ' + scope.link.icon); + elem.find('a').attr('target', scope.link.target); // fix for menus on the far right if (link.asDropdown && scope.$last) { @@ -143,8 +144,8 @@ export class DashLinksContainerCtrl { if (dash.id !== currentDashId) { memo.push({ title: dash.title, - url: 'dashboard/' + dash.uri, - target: link.target, + url: dash.url, + target: link.target === '_self' ? '' : link.target, icon: 'fa fa-th-large', keepTime: link.keepTime, includeVars: link.includeVars, diff --git a/public/app/features/org/all.ts b/public/app/features/org/all.ts index 97e01c53fe3..8872450e3ab 100644 --- a/public/app/features/org/all.ts +++ b/public/app/features/org/all.ts @@ -5,8 +5,6 @@ import './select_org_ctrl'; import './change_password_ctrl'; import './new_org_ctrl'; import './user_invite_ctrl'; -import './teams_ctrl'; -import './team_details_ctrl'; import './create_team_ctrl'; import './org_api_keys_ctrl'; import './org_details_ctrl'; diff --git a/public/app/features/org/partials/profile.html b/public/app/features/org/partials/profile.html index 66e41fbb4b4..7858e00c683 100644 --- a/public/app/features/org/partials/profile.html +++ b/public/app/features/org/partials/profile.html @@ -3,53 +3,74 @@

User Profile

- + -
- Name - -
-
- Email - +
+ Name + +
+
+ Email +
-
- Username +
+ Username
-
- -
- +
+ +
+ - + -

Organizations

+

Teams

+
+ + + + + + + + + + + + + + + + + +
NameEmailMembers
{{team.name}}{{team.email}}{{team.memberCount}}
+
+ +

Organizations

- - - - - - - - - - - - - - - -
NameRole
{{org.name}}{{org.role}} - - Current - - - Select - -
-
- + + + + + + + + + + + + + + + +
NameRole
{{org.name}}{{org.role}} + + Current + + + Select + +
+
diff --git a/public/app/features/org/partials/team_details.html b/public/app/features/org/partials/team_details.html deleted file mode 100644 index 3fce8b3c720..00000000000 --- a/public/app/features/org/partials/team_details.html +++ /dev/null @@ -1,67 +0,0 @@ - - -
-

Team Details

- -
-
- Name - -
-
- - Email - - This is optional and is primarily used for allowing custom team avatars. - - - -
- -
- -
-
- -
- -

Team Members

-
-
- Add member - - -
-
- - - - - - - - - - - - - - - - -
UsernameEmail
{{member.login}}{{member.email}} - - - -
-
- - This team has no members yet. - -
- diff --git a/public/app/features/org/partials/teams.html b/public/app/features/org/partials/teams.html deleted file mode 100755 index e15a15cf573..00000000000 --- a/public/app/features/org/partials/teams.html +++ /dev/null @@ -1,68 +0,0 @@ - - -
-
- -
- - - - Add Team - -
- -
- - - - - - - - - - - - - - - - - - - -
NameEmailMembers
- - - -
-
- -
-
    -
  1. - -
  2. -
-
- - - No Teams found. - -
diff --git a/public/app/features/org/profile_ctrl.ts b/public/app/features/org/profile_ctrl.ts index 5c62a7a5fdb..40ee4d908a1 100644 --- a/public/app/features/org/profile_ctrl.ts +++ b/public/app/features/org/profile_ctrl.ts @@ -4,8 +4,10 @@ import { coreModule } from 'app/core/core'; export class ProfileCtrl { user: any; old_theme: any; + teams: any = []; orgs: any = []; userForm: any; + showTeamsList = false; showOrgsList = false; readonlyLoginFields = config.disableLoginForm; navModel: any; @@ -13,6 +15,7 @@ export class ProfileCtrl { /** @ngInject **/ constructor(private backendSrv, private contextSrv, private $location, navModelSrv) { this.getUser(); + this.getUserTeams(); this.getUserOrgs(); this.navModel = navModelSrv.getNav('profile', 'profile-settings', 0); } @@ -24,6 +27,13 @@ export class ProfileCtrl { }); } + getUserTeams() { + this.backendSrv.get('/api/user/teams').then(teams => { + this.teams = teams; + this.showTeamsList = this.teams.length > 0; + }); + } + getUserOrgs() { this.backendSrv.get('/api/user/orgs').then(orgs => { this.orgs = orgs; diff --git a/public/app/features/org/specs/team_details_ctrl.jest.ts b/public/app/features/org/specs/team_details_ctrl.jest.ts deleted file mode 100644 index c636de7ec56..00000000000 --- a/public/app/features/org/specs/team_details_ctrl.jest.ts +++ /dev/null @@ -1,42 +0,0 @@ -import '../team_details_ctrl'; -import TeamDetailsCtrl from '../team_details_ctrl'; - -describe('TeamDetailsCtrl', () => { - var backendSrv = { - searchUsers: jest.fn(() => Promise.resolve([])), - get: jest.fn(() => Promise.resolve([])), - post: jest.fn(() => Promise.resolve([])), - }; - - //Team id - var routeParams = { - id: 1, - }; - - var navModelSrv = { - getNav: jest.fn(), - }; - - var teamDetailsCtrl = new TeamDetailsCtrl({ $broadcast: jest.fn() }, backendSrv, routeParams, navModelSrv); - - describe('when user is chosen to be added to team', () => { - beforeEach(() => { - teamDetailsCtrl = new TeamDetailsCtrl({ $broadcast: jest.fn() }, backendSrv, routeParams, navModelSrv); - const userItem = { - id: 2, - login: 'user2', - }; - teamDetailsCtrl.userPicked(userItem); - }); - - it('should parse the result and save to db', () => { - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/teams/1/members'); - expect(backendSrv.post.mock.calls[0][1].userId).toBe(2); - }); - - it('should refresh the list after saving.', () => { - expect(backendSrv.get.mock.calls[0][0]).toBe('/api/teams/1'); - expect(backendSrv.get.mock.calls[1][0]).toBe('/api/teams/1/members'); - }); - }); -}); diff --git a/public/app/features/org/team_details_ctrl.ts b/public/app/features/org/team_details_ctrl.ts deleted file mode 100644 index 3d193880635..00000000000 --- a/public/app/features/org/team_details_ctrl.ts +++ /dev/null @@ -1,81 +0,0 @@ -import coreModule from 'app/core/core_module'; - -export default class TeamDetailsCtrl { - team: Team; - teamMembers: User[] = []; - navModel: any; - - /** @ngInject **/ - constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) { - this.navModel = navModelSrv.getNav('cfg', 'teams', 0); - this.userPicked = this.userPicked.bind(this); - this.get = this.get.bind(this); - this.get(); - } - - get() { - if (this.$routeParams && this.$routeParams.id) { - this.backendSrv.get(`/api/teams/${this.$routeParams.id}`).then(result => { - this.team = result; - }); - this.backendSrv.get(`/api/teams/${this.$routeParams.id}/members`).then(result => { - this.teamMembers = result; - }); - } - } - - removeTeamMember(teamMember: TeamMember) { - this.$scope.appEvent('confirm-modal', { - title: 'Remove Member', - text: 'Are you sure you want to remove ' + teamMember.login + ' from this group?', - yesText: 'Remove', - icon: 'fa-warning', - onConfirm: () => { - this.removeMemberConfirmed(teamMember); - }, - }); - } - - removeMemberConfirmed(teamMember: TeamMember) { - this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/members/${teamMember.userId}`).then(this.get); - } - - update() { - if (!this.$scope.teamDetailsForm.$valid) { - return; - } - - this.backendSrv.put('/api/teams/' + this.team.id, { - name: this.team.name, - email: this.team.email, - }); - } - - userPicked(user) { - this.backendSrv.post(`/api/teams/${this.$routeParams.id}/members`, { userId: user.id }).then(() => { - this.$scope.$broadcast('user-picker-reset'); - this.get(); - }); - } -} - -export interface Team { - id: number; - name: string; - email: string; -} - -export interface User { - id: number; - name: string; - login: string; - email: string; -} - -export interface TeamMember { - userId: number; - name: string; - login: string; -} - -coreModule.controller('TeamDetailsCtrl', TeamDetailsCtrl); diff --git a/public/app/features/org/teams_ctrl.ts b/public/app/features/org/teams_ctrl.ts deleted file mode 100644 index 29317e73d3b..00000000000 --- a/public/app/features/org/teams_ctrl.ts +++ /dev/null @@ -1,66 +0,0 @@ -import coreModule from 'app/core/core_module'; -import appEvents from 'app/core/app_events'; - -export class TeamsCtrl { - teams: any; - pages = []; - perPage = 50; - page = 1; - totalPages: number; - showPaging = false; - query: any = ''; - navModel: any; - - /** @ngInject */ - constructor(private backendSrv, navModelSrv) { - this.navModel = navModelSrv.getNav('cfg', 'teams', 0); - this.get(); - } - - get() { - this.backendSrv - .get(`/api/teams/search?perpage=${this.perPage}&page=${this.page}&query=${this.query}`) - .then(result => { - this.teams = result.teams; - this.page = result.page; - this.perPage = result.perPage; - this.totalPages = Math.ceil(result.totalCount / result.perPage); - this.showPaging = this.totalPages > 1; - this.pages = []; - - for (var i = 1; i < this.totalPages + 1; i++) { - this.pages.push({ page: i, current: i === this.page }); - } - }); - } - - navigateToPage(page) { - this.page = page.page; - this.get(); - } - - deleteTeam(team) { - appEvents.emit('confirm-modal', { - title: 'Delete', - text: 'Are you sure you want to delete Team ' + team.name + '?', - yesText: 'Delete', - icon: 'fa-warning', - onConfirm: () => { - this.deleteTeamConfirmed(team); - }, - }); - } - - deleteTeamConfirmed(team) { - this.backendSrv.delete('/api/teams/' + team.id).then(this.get.bind(this)); - } - - openTeamModal() { - appEvents.emit('show-modal', { - templateHtml: '', - modalClass: 'modal--narrow', - }); - } -} - -coreModule.controller('TeamsCtrl', TeamsCtrl); diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 75c0de3bc6e..3d8a4ed3736 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -332,7 +332,7 @@ class MetricsPanelCtrl extends PanelCtrl { range, }; const exploreState = encodePathComponent(JSON.stringify(state)); - this.$location.url(`/explore/${exploreState}`); + this.$location.url(`/explore?state=${exploreState}`); } addQuery(target) { diff --git a/public/app/features/panel/specs/metrics_panel_ctrl.jest.ts b/public/app/features/panel/specs/metrics_panel_ctrl.test.ts similarity index 100% rename from public/app/features/panel/specs/metrics_panel_ctrl.jest.ts rename to public/app/features/panel/specs/metrics_panel_ctrl.test.ts diff --git a/public/app/features/panellinks/link_srv.ts b/public/app/features/panellinks/link_srv.ts index b20294485a5..9aee17f83ed 100644 --- a/public/app/features/panellinks/link_srv.ts +++ b/public/app/features/panellinks/link_srv.ts @@ -77,6 +77,10 @@ export class LinkSrv { info.target = link.targetBlank ? '_blank' : '_self'; info.href = this.templateSrv.replace(link.url || '', scopedVars); info.title = this.templateSrv.replace(link.title || '', scopedVars); + } else if (link.url) { + info.href = link.url; + info.title = this.templateSrv.replace(link.title || '', scopedVars); + info.target = link.targetBlank ? '_blank' : ''; } else if (link.dashUri) { info.href = 'dashboard/' + link.dashUri + '?'; info.title = this.templateSrv.replace(link.title || '', scopedVars); diff --git a/public/app/features/panellinks/module.ts b/public/app/features/panellinks/module.ts index 034e99f4296..66d4bd5b37f 100644 --- a/public/app/features/panellinks/module.ts +++ b/public/app/features/panellinks/module.ts @@ -39,7 +39,12 @@ export class PanelLinksEditorCtrl { backendSrv.search({ query: link.dashboard }).then(function(hits) { var dashboard = _.find(hits, { title: link.dashboard }); if (dashboard) { - link.dashUri = dashboard.uri; + if (dashboard.url) { + link.url = dashboard.url; + } else { + // To support legacy url's + link.dashUri = dashboard.uri; + } link.title = dashboard.title; } }); diff --git a/public/app/features/panellinks/specs/link_srv.jest.ts b/public/app/features/panellinks/specs/link_srv.test.ts similarity index 100% rename from public/app/features/panellinks/specs/link_srv.jest.ts rename to public/app/features/panellinks/specs/link_srv.test.ts diff --git a/public/app/features/playlist/specs/playlist_edit_ctrl.jest.ts b/public/app/features/playlist/specs/playlist_edit_ctrl.test.ts similarity index 100% rename from public/app/features/playlist/specs/playlist_edit_ctrl.jest.ts rename to public/app/features/playlist/specs/playlist_edit_ctrl.test.ts diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 6998321dd75..2c5bf459eda 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -4,11 +4,13 @@ import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/modul import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module'; import * as grafanaPlugin from 'app/plugins/datasource/grafana/module'; import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module'; +import * as loggingPlugin from 'app/plugins/datasource/logging/module'; import * as mixedPlugin from 'app/plugins/datasource/mixed/module'; import * as mysqlPlugin from 'app/plugins/datasource/mysql/module'; import * as postgresPlugin from 'app/plugins/datasource/postgres/module'; import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module'; import * as mssqlPlugin from 'app/plugins/datasource/mssql/module'; +import * as testDataDSPlugin from 'app/plugins/datasource/testdata/module'; import * as textPanel from 'app/plugins/panel/text/module'; import * as graphPanel from 'app/plugins/panel/graph/module'; @@ -20,9 +22,6 @@ import * as tablePanel from 'app/plugins/panel/table/module'; import * as singlestatPanel from 'app/plugins/panel/singlestat/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; -import * as testDataAppPlugin from 'app/plugins/app/testdata/module'; -import * as testDataDSPlugin from 'app/plugins/app/testdata/datasource/module'; - const builtInPlugins = { 'app/plugins/datasource/graphite/module': graphitePlugin, 'app/plugins/datasource/cloudwatch/module': cloudwatchPlugin, @@ -30,13 +29,13 @@ const builtInPlugins = { 'app/plugins/datasource/opentsdb/module': opentsdbPlugin, 'app/plugins/datasource/grafana/module': grafanaPlugin, 'app/plugins/datasource/influxdb/module': influxdbPlugin, + 'app/plugins/datasource/logging/module': loggingPlugin, 'app/plugins/datasource/mixed/module': mixedPlugin, 'app/plugins/datasource/mysql/module': mysqlPlugin, 'app/plugins/datasource/postgres/module': postgresPlugin, 'app/plugins/datasource/mssql/module': mssqlPlugin, 'app/plugins/datasource/prometheus/module': prometheusPlugin, - 'app/plugins/app/testdata/module': testDataAppPlugin, - 'app/plugins/app/testdata/datasource/module': testDataDSPlugin, + 'app/plugins/datasource/testdata/module': testDataDSPlugin, 'app/plugins/panel/text/module': textPanel, 'app/plugins/panel/graph/module': graphPanel, diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 19bdf599ce1..df743640062 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -34,13 +34,13 @@ export class DatasourceSrv { } loadDatasource(name) { - var dsConfig = config.datasources[name]; + const dsConfig = config.datasources[name]; if (!dsConfig) { return this.$q.reject({ message: 'Datasource named ' + name + ' was not found' }); } - var deferred = this.$q.defer(); - var pluginDef = dsConfig.meta; + const deferred = this.$q.defer(); + const pluginDef = dsConfig.meta; importPluginModule(pluginDef.module) .then(plugin => { @@ -55,7 +55,7 @@ export class DatasourceSrv { throw new Error('Plugin module is missing Datasource constructor'); } - var instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig }); + const instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig }); instance.meta = pluginDef; instance.name = name; this.datasources[name] = instance; @@ -73,7 +73,7 @@ export class DatasourceSrv { } getAnnotationSources() { - var sources = []; + const sources = []; this.addDataSourceVariables(sources); @@ -86,15 +86,33 @@ export class DatasourceSrv { return sources; } + getExploreSources() { + const { datasources } = config; + const es = Object.keys(datasources) + .map(name => datasources[name]) + .filter(ds => ds.meta && ds.meta.explore); + return _.sortBy(es, ['name']); + } + getMetricSources(options) { var metricSources = []; _.each(config.datasources, function(value, key) { if (value.meta && value.meta.metrics) { - metricSources.push({ value: key, name: key, meta: value.meta }); + let metricSource = { value: key, name: key, meta: value.meta, sort: key }; + + //Make sure grafana and mixed are sorted at the bottom + if (value.meta.id === 'grafana') { + metricSource.sort = String.fromCharCode(253); + } else if (value.meta.id === 'mixed') { + metricSource.sort = String.fromCharCode(254); + } + + metricSources.push(metricSource); if (key === config.defaultDatasource) { - metricSources.push({ value: null, name: 'default', meta: value.meta }); + metricSource = { value: null, name: 'default', meta: value.meta, sort: key }; + metricSources.push(metricSource); } } }); @@ -104,17 +122,10 @@ export class DatasourceSrv { } metricSources.sort(function(a, b) { - // these two should always be at the bottom - if (a.meta.id === 'mixed' || a.meta.id === 'grafana') { + if (a.sort.toLowerCase() > b.sort.toLowerCase()) { return 1; } - if (b.meta.id === 'mixed' || b.meta.id === 'grafana') { - return -1; - } - if (a.name.toLowerCase() > b.name.toLowerCase()) { - return 1; - } - if (a.name.toLowerCase() < b.name.toLowerCase()) { + if (a.sort.toLowerCase() < b.sort.toLowerCase()) { return -1; } return 0; @@ -139,10 +150,12 @@ export class DatasourceSrv { var ds = config.datasources[first]; if (ds) { + const key = `$${variable.name}`; list.push({ - name: '$' + variable.name, - value: '$' + variable.name, + name: key, + value: key, meta: ds.meta, + sort: key, }); } } @@ -150,3 +163,4 @@ export class DatasourceSrv { } coreModule.service('datasourceSrv', DatasourceSrv); +export default DatasourceSrv; diff --git a/public/app/features/plugins/ds_list_ctrl.ts b/public/app/features/plugins/ds_list_ctrl.ts index 577b931551a..89c760ae253 100644 --- a/public/app/features/plugins/ds_list_ctrl.ts +++ b/public/app/features/plugins/ds_list_ctrl.ts @@ -19,6 +19,7 @@ export class DataSourcesCtrl { onQueryUpdated() { let regex = new RegExp(this.searchQuery, 'ig'); this.datasources = _.filter(this.unfiltered, item => { + regex.lastIndex = 0; return regex.test(item.name) || regex.test(item.type); }); } diff --git a/public/app/features/plugins/partials/ds_http_settings.html b/public/app/features/plugins/partials/ds_http_settings.html index 3af185d862c..6d014af567c 100644 --- a/public/app/features/plugins/partials/ds_http_settings.html +++ b/public/app/features/plugins/partials/ds_http_settings.html @@ -32,8 +32,8 @@
diff --git a/public/app/features/plugins/plugin_edit_ctrl.ts b/public/app/features/plugins/plugin_edit_ctrl.ts index 1244e6e38f7..6aa8b2bc38f 100644 --- a/public/app/features/plugins/plugin_edit_ctrl.ts +++ b/public/app/features/plugins/plugin_edit_ctrl.ts @@ -97,7 +97,9 @@ export class PluginEditCtrl { initReadme() { return this.backendSrv.get(`/api/plugins/${this.pluginId}/markdown/readme`).then(res => { - var md = new Remarkable(); + var md = new Remarkable({ + linkify: true + }); this.readmeHtml = this.$sce.trustAsHtml(md.render(res)); }); } diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index f999ee7e2ff..cce494d0a60 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -56,7 +56,7 @@ System.config({ css: 'vendor/plugin-css/css.js', }, meta: { - 'plugin*': { + '/*': { esModule: true, authorization: true, loader: 'plugin-loader', @@ -126,6 +126,7 @@ import 'vendor/flot/jquery.flot.stackpercent'; import 'vendor/flot/jquery.flot.fillbelow'; import 'vendor/flot/jquery.flot.crosshair'; import 'vendor/flot/jquery.flot.dashes'; +import 'vendor/flot/jquery.flot.gauge'; const flotDeps = [ 'jquery.flot', @@ -137,6 +138,7 @@ const flotDeps = [ 'jquery.flot.selection', 'jquery.flot.stackpercent', 'jquery.flot.events', + 'jquery.flot.gauge', ]; for (let flotDep of flotDeps) { exposeToPlugin(flotDep, { fakeDep: 1 }); diff --git a/public/app/features/plugins/specs/datasource_srv.test.ts b/public/app/features/plugins/specs/datasource_srv.test.ts new file mode 100644 index 00000000000..b63e8537837 --- /dev/null +++ b/public/app/features/plugins/specs/datasource_srv.test.ts @@ -0,0 +1,100 @@ +import config from 'app/core/config'; +import 'app/features/plugins/datasource_srv'; +import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; + +// Datasource variable $datasource with current value 'BBB' +const templateSrv = { + variables: [ + { + type: 'datasource', + name: 'datasource', + current: { + value: 'BBB', + }, + }, + ], +}; + +describe('datasource_srv', function() { + let _datasourceSrv = new DatasourceSrv({}, {}, {}, templateSrv); + + describe('when loading explore sources', () => { + beforeEach(() => { + config.datasources = { + explore1: { + name: 'explore1', + meta: { explore: true, metrics: true }, + }, + explore2: { + name: 'explore2', + meta: { explore: true, metrics: false }, + }, + nonExplore: { + name: 'nonExplore', + meta: { explore: false, metrics: true }, + }, + }; + }); + + it('should return list of explore sources', () => { + const exploreSources = _datasourceSrv.getExploreSources(); + expect(exploreSources.length).toBe(2); + expect(exploreSources[0].name).toBe('explore1'); + expect(exploreSources[1].name).toBe('explore2'); + }); + }); + + describe('when loading metric sources', () => { + let metricSources; + let unsortedDatasources = { + mmm: { + type: 'test-db', + meta: { metrics: { m: 1 } }, + }, + '--Grafana--': { + type: 'grafana', + meta: { builtIn: true, metrics: { m: 1 }, id: 'grafana' }, + }, + '--Mixed--': { + type: 'test-db', + meta: { builtIn: true, metrics: { m: 1 }, id: 'mixed' }, + }, + ZZZ: { + type: 'test-db', + meta: { metrics: { m: 1 } }, + }, + aaa: { + type: 'test-db', + meta: { metrics: { m: 1 } }, + }, + BBB: { + type: 'test-db', + meta: { metrics: { m: 1 } }, + }, + }; + beforeEach(() => { + config.datasources = unsortedDatasources; + metricSources = _datasourceSrv.getMetricSources({}); + config.defaultDatasource = 'BBB'; + }); + + it('should return a list of sources sorted case insensitively with builtin sources last', () => { + expect(metricSources[1].name).toBe('aaa'); + expect(metricSources[2].name).toBe('BBB'); + expect(metricSources[3].name).toBe('mmm'); + expect(metricSources[4].name).toBe('ZZZ'); + expect(metricSources[5].name).toBe('--Grafana--'); + expect(metricSources[6].name).toBe('--Mixed--'); + }); + + it('should set default data source', () => { + expect(metricSources[3].name).toBe('default'); + expect(metricSources[3].sort).toBe('BBB'); + }); + + it('should set default inject the variable datasources', () => { + expect(metricSources[0].name).toBe('$datasource'); + expect(metricSources[0].sort).toBe('$datasource'); + }); + }); +}); diff --git a/public/app/features/plugins/specs/datasource_srv_specs.ts b/public/app/features/plugins/specs/datasource_srv_specs.ts deleted file mode 100644 index 85a66b59ee7..00000000000 --- a/public/app/features/plugins/specs/datasource_srv_specs.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common'; -import config from 'app/core/config'; -import 'app/features/plugins/datasource_srv'; - -describe('datasource_srv', function() { - var _datasourceSrv; - var metricSources; - var templateSrv = {}; - - beforeEach(angularMocks.module('grafana.core')); - beforeEach( - angularMocks.module(function($provide) { - $provide.value('templateSrv', templateSrv); - }) - ); - beforeEach(angularMocks.module('grafana.services')); - beforeEach( - angularMocks.inject(function(datasourceSrv) { - _datasourceSrv = datasourceSrv; - }) - ); - - describe('when loading metric sources', function() { - var unsortedDatasources = { - mmm: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - '--Grafana--': { - type: 'grafana', - meta: { builtIn: true, metrics: { m: 1 }, id: 'grafana' }, - }, - '--Mixed--': { - type: 'test-db', - meta: { builtIn: true, metrics: { m: 1 }, id: 'mixed' }, - }, - ZZZ: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - aaa: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - BBB: { - type: 'test-db', - meta: { metrics: { m: 1 } }, - }, - }; - beforeEach(function() { - config.datasources = unsortedDatasources; - metricSources = _datasourceSrv.getMetricSources({ skipVariables: true }); - }); - - it('should return a list of sources sorted case insensitively with builtin sources last', function() { - expect(metricSources[0].name).to.be('aaa'); - expect(metricSources[1].name).to.be('BBB'); - expect(metricSources[2].name).to.be('mmm'); - expect(metricSources[3].name).to.be('ZZZ'); - expect(metricSources[4].name).to.be('--Grafana--'); - expect(metricSources[5].name).to.be('--Mixed--'); - }); - }); -}); diff --git a/public/app/features/templating/adhoc_variable.ts b/public/app/features/templating/adhoc_variable.ts index babeaf1f34e..9f8bd4c39a7 100644 --- a/public/app/features/templating/adhoc_variable.ts +++ b/public/app/features/templating/adhoc_variable.ts @@ -3,6 +3,7 @@ import { Variable, assignModelProperties, variableTypes } from './variable'; export class AdhocVariable implements Variable { filters: any[]; + skipUrlSync: boolean; defaults = { type: 'adhoc', @@ -11,6 +12,7 @@ export class AdhocVariable implements Variable { hide: 0, datasource: null, filters: [], + skipUrlSync: false, }; /** @ngInject **/ diff --git a/public/app/features/templating/constant_variable.ts b/public/app/features/templating/constant_variable.ts index f2fb4294537..e727c6e98af 100644 --- a/public/app/features/templating/constant_variable.ts +++ b/public/app/features/templating/constant_variable.ts @@ -4,6 +4,7 @@ export class ConstantVariable implements Variable { query: string; options: any[]; current: any; + skipUrlSync: boolean; defaults = { type: 'constant', @@ -13,6 +14,7 @@ export class ConstantVariable implements Variable { query: '', current: {}, options: [], + skipUrlSync: false, }; /** @ngInject **/ diff --git a/public/app/features/templating/custom_variable.ts b/public/app/features/templating/custom_variable.ts index c15178f6644..4490a41a38f 100644 --- a/public/app/features/templating/custom_variable.ts +++ b/public/app/features/templating/custom_variable.ts @@ -7,6 +7,7 @@ export class CustomVariable implements Variable { includeAll: boolean; multi: boolean; current: any; + skipUrlSync: boolean; defaults = { type: 'custom', @@ -19,6 +20,7 @@ export class CustomVariable implements Variable { includeAll: false, multi: false, allValue: null, + skipUrlSync: false, }; /** @ngInject **/ diff --git a/public/app/features/templating/datasource_variable.ts b/public/app/features/templating/datasource_variable.ts index 4c326a94e3b..519ce21e4d4 100644 --- a/public/app/features/templating/datasource_variable.ts +++ b/public/app/features/templating/datasource_variable.ts @@ -7,6 +7,7 @@ export class DatasourceVariable implements Variable { options: any; current: any; refresh: any; + skipUrlSync: boolean; defaults = { type: 'datasource', @@ -18,6 +19,7 @@ export class DatasourceVariable implements Variable { options: [], query: '', refresh: 1, + skipUrlSync: false, }; /** @ngInject **/ diff --git a/public/app/features/templating/interval_variable.ts b/public/app/features/templating/interval_variable.ts index 3faac316f98..b932819a7b7 100644 --- a/public/app/features/templating/interval_variable.ts +++ b/public/app/features/templating/interval_variable.ts @@ -11,6 +11,7 @@ export class IntervalVariable implements Variable { query: string; refresh: number; current: any; + skipUrlSync: boolean; defaults = { type: 'interval', @@ -24,6 +25,7 @@ export class IntervalVariable implements Variable { auto: false, auto_min: '10s', auto_count: 30, + skipUrlSync: false, }; /** @ngInject **/ diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index 54bd7bb660c..827fd80a176 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -22,6 +22,7 @@ export class QueryVariable implements Variable { tagsQuery: string; tagValuesQuery: string; tags: any[]; + skipUrlSync: boolean; defaults = { type: 'query', @@ -42,6 +43,7 @@ export class QueryVariable implements Variable { useTags: false, tagsQuery: '', tagValuesQuery: '', + skipUrlSync: false, }; /** @ngInject **/ @@ -211,7 +213,7 @@ export class QueryVariable implements Variable { } dependsOn(variable) { - return containsVariable(this.query, this.datasource, variable.name); + return containsVariable(this.query, this.datasource, this.regex, variable.name); } } diff --git a/public/app/features/templating/specs/adhoc_variable.jest.ts b/public/app/features/templating/specs/adhoc_variable.test.ts similarity index 100% rename from public/app/features/templating/specs/adhoc_variable.jest.ts rename to public/app/features/templating/specs/adhoc_variable.test.ts diff --git a/public/app/features/templating/specs/editor_ctrl.jest.ts b/public/app/features/templating/specs/editor_ctrl.test.ts similarity index 100% rename from public/app/features/templating/specs/editor_ctrl.jest.ts rename to public/app/features/templating/specs/editor_ctrl.test.ts diff --git a/public/app/features/templating/specs/query_variable.jest.ts b/public/app/features/templating/specs/query_variable.test.ts similarity index 100% rename from public/app/features/templating/specs/query_variable.jest.ts rename to public/app/features/templating/specs/query_variable.test.ts diff --git a/public/app/features/templating/specs/template_srv.jest.ts b/public/app/features/templating/specs/template_srv.test.ts similarity index 89% rename from public/app/features/templating/specs/template_srv.jest.ts rename to public/app/features/templating/specs/template_srv.test.ts index 59915776b4f..86b6aa7ec99 100644 --- a/public/app/features/templating/specs/template_srv.jest.ts +++ b/public/app/features/templating/specs/template_srv.test.ts @@ -345,6 +345,49 @@ describe('templateSrv', function() { }); }); + describe('fillVariableValuesForUrl skip url sync', function() { + beforeEach(function() { + initTemplateSrv([ + { + name: 'test', + skipUrlSync: true, + current: { value: 'value' }, + getValueForUrl: function() { + return this.current.value; + }, + }, + ]); + }); + + it('should not include template variable value in url', function() { + var params = {}; + _templateSrv.fillVariableValuesForUrl(params); + expect(params['var-test']).toBe(undefined); + }); + }); + + describe('fillVariableValuesForUrl with multi value with skip url sync', function() { + beforeEach(function() { + initTemplateSrv([ + { + type: 'query', + name: 'test', + skipUrlSync: true, + current: { value: ['val1', 'val2'] }, + getValueForUrl: function() { + return this.current.value; + }, + }, + ]); + }); + + it('should not include template variable value in url', function() { + var params = {}; + _templateSrv.fillVariableValuesForUrl(params); + expect(params['var-test']).toBe(undefined); + }); + }); + describe('fillVariableValuesForUrl with multi value and scopedVars', function() { beforeEach(function() { initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]); @@ -359,6 +402,20 @@ describe('templateSrv', function() { }); }); + describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', function() { + beforeEach(function() { + initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]); + }); + + it('should not set scoped value as url params', function() { + var params = {}; + _templateSrv.fillVariableValuesForUrl(params, { + test: { name: 'test', value: 'val1', skipUrlSync: true }, + }); + expect(params['var-test']).toBe(undefined); + }); + }); + describe('replaceWithText', function() { beforeEach(function() { initTemplateSrv([ diff --git a/public/app/features/templating/specs/variable.jest.ts b/public/app/features/templating/specs/variable.test.ts similarity index 100% rename from public/app/features/templating/specs/variable.jest.ts rename to public/app/features/templating/specs/variable.test.ts diff --git a/public/app/features/templating/specs/variable_srv_specs.ts b/public/app/features/templating/specs/variable_srv.test.ts similarity index 55% rename from public/app/features/templating/specs/variable_srv_specs.ts rename to public/app/features/templating/specs/variable_srv.test.ts index 6ab5dcad20e..f7796434b5e 100644 --- a/public/app/features/templating/specs/variable_srv_specs.ts +++ b/public/app/features/templating/specs/variable_srv.test.ts @@ -1,53 +1,70 @@ -import { describe, beforeEach, it, sinon, expect, angularMocks } from 'test/lib/common'; - import '../all'; - +import { VariableSrv } from '../variable_srv'; import moment from 'moment'; -import helpers from 'test/specs/helpers'; -import { Emitter } from 'app/core/core'; +import $q from 'q'; describe('VariableSrv', function() { - var ctx = new helpers.ControllerTestContext(); - - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.controllers')); - beforeEach(angularMocks.module('grafana.services')); - - beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location'])); - beforeEach( - angularMocks.inject(($rootScope, $q, $location, $injector) => { - ctx.$q = $q; - ctx.$rootScope = $rootScope; - ctx.$location = $location; - ctx.variableSrv = $injector.get('variableSrv'); - ctx.variableSrv.init({ - templating: { list: [] }, - events: new Emitter(), - updateSubmenuVisibility: sinon.stub(), - }); - ctx.$rootScope.$digest(); - }) - ); + var ctx = { + datasourceSrv: {}, + timeSrv: { + timeRange: () => {}, + }, + $rootScope: { + $on: () => {}, + }, + $injector: { + instantiate: (ctr, obj) => new ctr(obj.model), + }, + templateSrv: { + setGrafanaVariable: jest.fn(), + init: vars => { + this.variables = vars; + }, + updateTemplateData: () => {}, + replace: str => + str.replace(this.regex, match => { + return match; + }), + }, + $location: { + search: () => {}, + }, + }; function describeUpdateVariable(desc, fn) { - describe(desc, function() { + describe(desc, () => { var scenario: any = {}; scenario.setup = function(setupFn) { scenario.setupFn = setupFn; }; - beforeEach(function() { + beforeEach(async () => { scenario.setupFn(); + var ds: any = {}; - ds.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult)); - ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ds)); - ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources); + ds.metricFindQuery = () => Promise.resolve(scenario.queryResult); + + ctx.variableSrv = new VariableSrv(ctx.$rootScope, $q, ctx.$location, ctx.$injector, ctx.templateSrv); + + ctx.variableSrv.timeSrv = ctx.timeSrv; + ctx.datasourceSrv = { + get: () => Promise.resolve(ds), + getMetricSources: () => scenario.metricSources, + }; + + ctx.$injector.instantiate = (ctr, model) => { + return getVarMockConstructor(ctr, model, ctx); + }; + + ctx.variableSrv.init({ + templating: { list: [] }, + updateSubmenuVisibility: () => {}, + }); scenario.variable = ctx.variableSrv.createVariableFromModel(scenario.variableModel); ctx.variableSrv.addVariable(scenario.variable); - ctx.variableSrv.updateOptions(scenario.variable); - ctx.$rootScope.$digest(); + await ctx.variableSrv.updateOptions(scenario.variable); }); fn(scenario); @@ -64,9 +81,9 @@ describe('VariableSrv', function() { }); it('should update options array', () => { - expect(scenario.variable.options.length).to.be(4); - expect(scenario.variable.options[0].text).to.be('1s'); - expect(scenario.variable.options[0].value).to.be('1s'); + expect(scenario.variable.options.length).toBe(4); + expect(scenario.variable.options[0].text).toBe('1s'); + expect(scenario.variable.options[0].value).toBe('1s'); }); }); @@ -90,28 +107,28 @@ describe('VariableSrv', function() { to: new Date(), }; - ctx.timeSrv.timeRange = sinon.stub().returns(range); - ctx.templateSrv.setGrafanaVariable = sinon.spy(); + ctx.timeSrv.timeRange = () => range; + // ctx.templateSrv.setGrafanaVariable = jest.fn(); }); - it('should update options array', function() { - expect(scenario.variable.options.length).to.be(5); - expect(scenario.variable.options[0].text).to.be('auto'); - expect(scenario.variable.options[0].value).to.be('$__auto_interval_test'); + it('should update options array', () => { + expect(scenario.variable.options.length).toBe(5); + expect(scenario.variable.options[0].text).toBe('auto'); + expect(scenario.variable.options[0].value).toBe('$__auto_interval_test'); }); - it('should set $__auto_interval_test', function() { - var call = ctx.templateSrv.setGrafanaVariable.firstCall; - expect(call.args[0]).to.be('$__auto_interval_test'); - expect(call.args[1]).to.be('12h'); + it('should set $__auto_interval_test', () => { + var call = ctx.templateSrv.setGrafanaVariable.mock.calls[0]; + expect(call[0]).toBe('$__auto_interval_test'); + expect(call[1]).toBe('12h'); }); // updateAutoValue() gets called twice: once directly once via VariableSrv.validateVariableSelectionState() // So use lastCall instead of a specific call number - it('should set $__auto_interval', function() { - var call = ctx.templateSrv.setGrafanaVariable.lastCall; - expect(call.args[0]).to.be('$__auto_interval'); - expect(call.args[1]).to.be('12h'); + it('should set $__auto_interval', () => { + var call = ctx.templateSrv.setGrafanaVariable.mock.calls.pop(); + expect(call[0]).toBe('$__auto_interval'); + expect(call[1]).toBe('12h'); }); }); @@ -119,7 +136,7 @@ describe('VariableSrv', function() { // Query variable update // describeUpdateVariable('query variable with empty current object and refresh', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: '', @@ -129,16 +146,16 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'backend1' }, { text: 'backend2' }]; }); - it('should set current value to first option', function() { - expect(scenario.variable.options.length).to.be(2); - expect(scenario.variable.current.value).to.be('backend1'); + it('should set current value to first option', () => { + expect(scenario.variable.options.length).toBe(2); + expect(scenario.variable.current.value).toBe('backend1'); }); }); describeUpdateVariable( 'query variable with multi select and new options does not contain some selected values', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: '', @@ -151,9 +168,9 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'val2' }, { text: 'val3' }]; }); - it('should update current value', function() { - expect(scenario.variable.current.value).to.eql(['val2', 'val3']); - expect(scenario.variable.current.text).to.eql('val2 + val3'); + it('should update current value', () => { + expect(scenario.variable.current.value).toEqual(['val2', 'val3']); + expect(scenario.variable.current.text).toEqual('val2 + val3'); }); } ); @@ -161,7 +178,7 @@ describe('VariableSrv', function() { describeUpdateVariable( 'query variable with multi select and new options does not contain any selected values', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: '', @@ -174,15 +191,15 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'val5' }, { text: 'val6' }]; }); - it('should update current value with first one', function() { - expect(scenario.variable.current.value).to.eql('val5'); - expect(scenario.variable.current.text).to.eql('val5'); + it('should update current value with first one', () => { + expect(scenario.variable.current.value).toEqual('val5'); + expect(scenario.variable.current.text).toEqual('val5'); }); } ); describeUpdateVariable('query variable with multi select and $__all selected', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: '', @@ -196,14 +213,14 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'val5' }, { text: 'val6' }]; }); - it('should keep current All value', function() { - expect(scenario.variable.current.value).to.eql(['$__all']); - expect(scenario.variable.current.text).to.eql('All'); + it('should keep current All value', () => { + expect(scenario.variable.current.value).toEqual(['$__all']); + expect(scenario.variable.current.text).toEqual('All'); }); }); describeUpdateVariable('query variable with numeric results', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: '', @@ -213,45 +230,45 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 12, value: 12 }]; }); - it('should set current value to first option', function() { - expect(scenario.variable.current.value).to.be('12'); - expect(scenario.variable.options[0].value).to.be('12'); - expect(scenario.variable.options[0].text).to.be('12'); + it('should set current value to first option', () => { + expect(scenario.variable.current.value).toBe('12'); + expect(scenario.variable.options[0].value).toBe('12'); + expect(scenario.variable.options[0].text).toBe('12'); }); }); describeUpdateVariable('basic query variable', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; scenario.queryResult = [{ text: 'backend1' }, { text: 'backend2' }]; }); - it('should update options array', function() { - expect(scenario.variable.options.length).to.be(2); - expect(scenario.variable.options[0].text).to.be('backend1'); - expect(scenario.variable.options[0].value).to.be('backend1'); - expect(scenario.variable.options[1].value).to.be('backend2'); + it('should update options array', () => { + expect(scenario.variable.options.length).toBe(2); + expect(scenario.variable.options[0].text).toBe('backend1'); + expect(scenario.variable.options[0].value).toBe('backend1'); + expect(scenario.variable.options[1].value).toBe('backend2'); }); - it('should select first option as value', function() { - expect(scenario.variable.current.value).to.be('backend1'); + it('should select first option as value', () => { + expect(scenario.variable.current.value).toBe('backend1'); }); }); describeUpdateVariable('and existing value still exists in options', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; scenario.variableModel.current = { value: 'backend2', text: 'backend2' }; scenario.queryResult = [{ text: 'backend1' }, { text: 'backend2' }]; }); - it('should keep variable value', function() { - expect(scenario.variable.current.text).to.be('backend2'); + it('should keep variable value', () => { + expect(scenario.variable.current.text).toBe('backend2'); }); }); describeUpdateVariable('and regex pattern exists', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; scenario.variableModel.regex = '/apps.*(backend_[0-9]+)/'; scenario.queryResult = [ @@ -260,13 +277,13 @@ describe('VariableSrv', function() { ]; }); - it('should extract and use match group', function() { - expect(scenario.variable.options[0].value).to.be('backend_01'); + it('should extract and use match group', () => { + expect(scenario.variable.options[0].value).toBe('backend_01'); }); }); describeUpdateVariable('and regex pattern exists and no match', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; scenario.variableModel.regex = '/apps.*(backendasd[0-9]+)/'; scenario.queryResult = [ @@ -275,14 +292,14 @@ describe('VariableSrv', function() { ]; }); - it('should not add non matching items, None option should be added instead', function() { - expect(scenario.variable.options.length).to.be(1); - expect(scenario.variable.options[0].isNone).to.be(true); + it('should not add non matching items, None option should be added instead', () => { + expect(scenario.variable.options.length).toBe(1); + expect(scenario.variable.options[0].isNone).toBe(true); }); }); describeUpdateVariable('regex pattern without slashes', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; scenario.variableModel.regex = 'backend_01'; scenario.queryResult = [ @@ -291,13 +308,13 @@ describe('VariableSrv', function() { ]; }); - it('should return matches options', function() { - expect(scenario.variable.options.length).to.be(1); + it('should return matches options', () => { + expect(scenario.variable.options.length).toBe(1); }); }); describeUpdateVariable('regex pattern remove duplicates', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; scenario.variableModel.regex = '/backend_01/'; scenario.queryResult = [ @@ -306,13 +323,13 @@ describe('VariableSrv', function() { ]; }); - it('should return matches options', function() { - expect(scenario.variable.options.length).to.be(1); + it('should return matches options', () => { + expect(scenario.variable.options.length).toBe(1); }); }); describeUpdateVariable('with include All', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -322,14 +339,14 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'backend1' }, { text: 'backend2' }, { text: 'backend3' }]; }); - it('should add All option', function() { - expect(scenario.variable.options[0].text).to.be('All'); - expect(scenario.variable.options[0].value).to.be('$__all'); + it('should add All option', () => { + expect(scenario.variable.options[0].text).toBe('All'); + expect(scenario.variable.options[0].value).toBe('$__all'); }); }); describeUpdateVariable('with include all and custom value', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -340,13 +357,13 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'backend1' }, { text: 'backend2' }, { text: 'backend3' }]; }); - it('should add All option with custom value', function() { - expect(scenario.variable.options[0].value).to.be('$__all'); + it('should add All option with custom value', () => { + expect(scenario.variable.options[0].value).toBe('$__all'); }); }); describeUpdateVariable('without sort', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -356,15 +373,15 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'bbb2' }, { text: 'aaa10' }, { text: 'ccc3' }]; }); - it('should return options without sort', function() { - expect(scenario.variable.options[0].text).to.be('bbb2'); - expect(scenario.variable.options[1].text).to.be('aaa10'); - expect(scenario.variable.options[2].text).to.be('ccc3'); + it('should return options without sort', () => { + expect(scenario.variable.options[0].text).toBe('bbb2'); + expect(scenario.variable.options[1].text).toBe('aaa10'); + expect(scenario.variable.options[2].text).toBe('ccc3'); }); }); describeUpdateVariable('with alphabetical sort (asc)', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -374,15 +391,15 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'bbb2' }, { text: 'aaa10' }, { text: 'ccc3' }]; }); - it('should return options with alphabetical sort', function() { - expect(scenario.variable.options[0].text).to.be('aaa10'); - expect(scenario.variable.options[1].text).to.be('bbb2'); - expect(scenario.variable.options[2].text).to.be('ccc3'); + it('should return options with alphabetical sort', () => { + expect(scenario.variable.options[0].text).toBe('aaa10'); + expect(scenario.variable.options[1].text).toBe('bbb2'); + expect(scenario.variable.options[2].text).toBe('ccc3'); }); }); describeUpdateVariable('with alphabetical sort (desc)', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -392,15 +409,15 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'bbb2' }, { text: 'aaa10' }, { text: 'ccc3' }]; }); - it('should return options with alphabetical sort', function() { - expect(scenario.variable.options[0].text).to.be('ccc3'); - expect(scenario.variable.options[1].text).to.be('bbb2'); - expect(scenario.variable.options[2].text).to.be('aaa10'); + it('should return options with alphabetical sort', () => { + expect(scenario.variable.options[0].text).toBe('ccc3'); + expect(scenario.variable.options[1].text).toBe('bbb2'); + expect(scenario.variable.options[2].text).toBe('aaa10'); }); }); describeUpdateVariable('with numerical sort (asc)', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -410,15 +427,15 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'bbb2' }, { text: 'aaa10' }, { text: 'ccc3' }]; }); - it('should return options with numerical sort', function() { - expect(scenario.variable.options[0].text).to.be('bbb2'); - expect(scenario.variable.options[1].text).to.be('ccc3'); - expect(scenario.variable.options[2].text).to.be('aaa10'); + it('should return options with numerical sort', () => { + expect(scenario.variable.options[0].text).toBe('bbb2'); + expect(scenario.variable.options[1].text).toBe('ccc3'); + expect(scenario.variable.options[2].text).toBe('aaa10'); }); }); describeUpdateVariable('with numerical sort (desc)', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'query', query: 'apps.*', @@ -428,10 +445,10 @@ describe('VariableSrv', function() { scenario.queryResult = [{ text: 'bbb2' }, { text: 'aaa10' }, { text: 'ccc3' }]; }); - it('should return options with numerical sort', function() { - expect(scenario.variable.options[0].text).to.be('aaa10'); - expect(scenario.variable.options[1].text).to.be('ccc3'); - expect(scenario.variable.options[2].text).to.be('bbb2'); + it('should return options with numerical sort', () => { + expect(scenario.variable.options[0].text).toBe('aaa10'); + expect(scenario.variable.options[1].text).toBe('ccc3'); + expect(scenario.variable.options[2].text).toBe('bbb2'); }); }); @@ -439,7 +456,7 @@ describe('VariableSrv', function() { // datasource variable update // describeUpdateVariable('datasource variable with regex filter', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'datasource', query: 'graphite', @@ -455,14 +472,14 @@ describe('VariableSrv', function() { ]; }); - it('should set only contain graphite ds and filtered using regex', function() { - expect(scenario.variable.options.length).to.be(2); - expect(scenario.variable.options[0].value).to.be('backend2_pee'); - expect(scenario.variable.options[1].value).to.be('backend4_pee'); + it('should set only contain graphite ds and filtered using regex', () => { + expect(scenario.variable.options.length).toBe(2); + expect(scenario.variable.options[0].value).toBe('backend2_pee'); + expect(scenario.variable.options[1].value).toBe('backend4_pee'); }); - it('should keep current value if available', function() { - expect(scenario.variable.current.value).to.be('backend4_pee'); + it('should keep current value if available', () => { + expect(scenario.variable.current.value).toBe('backend4_pee'); }); }); @@ -470,7 +487,7 @@ describe('VariableSrv', function() { // Custom variable update // describeUpdateVariable('update custom variable', function(scenario) { - scenario.setup(function() { + scenario.setup(() => { scenario.variableModel = { type: 'custom', query: 'hej, hop, asd', @@ -478,25 +495,25 @@ describe('VariableSrv', function() { }; }); - it('should update options array', function() { - expect(scenario.variable.options.length).to.be(3); - expect(scenario.variable.options[0].text).to.be('hej'); - expect(scenario.variable.options[1].value).to.be('hop'); + it('should update options array', () => { + expect(scenario.variable.options.length).toBe(3); + expect(scenario.variable.options[0].text).toBe('hej'); + expect(scenario.variable.options[1].value).toBe('hop'); }); }); - describe('multiple interval variables with auto', function() { + describe('multiple interval variables with auto', () => { var variable1, variable2; - beforeEach(function() { + beforeEach(() => { var range = { from: moment(new Date()) .subtract(7, 'days') .toDate(), to: new Date(), }; - ctx.timeSrv.timeRange = sinon.stub().returns(range); - ctx.templateSrv.setGrafanaVariable = sinon.spy(); + ctx.timeSrv.timeRange = () => range; + ctx.templateSrv.setGrafanaVariable = jest.fn(); var variableModel1 = { type: 'interval', @@ -520,38 +537,38 @@ describe('VariableSrv', function() { ctx.variableSrv.updateOptions(variable1); ctx.variableSrv.updateOptions(variable2); - ctx.$rootScope.$digest(); + // ctx.$rootScope.$digest(); }); - it('should update options array', function() { - expect(variable1.options.length).to.be(5); - expect(variable1.options[0].text).to.be('auto'); - expect(variable1.options[0].value).to.be('$__auto_interval_variable1'); - expect(variable2.options.length).to.be(4); - expect(variable2.options[0].text).to.be('auto'); - expect(variable2.options[0].value).to.be('$__auto_interval_variable2'); + it('should update options array', () => { + expect(variable1.options.length).toBe(5); + expect(variable1.options[0].text).toBe('auto'); + expect(variable1.options[0].value).toBe('$__auto_interval_variable1'); + expect(variable2.options.length).toBe(4); + expect(variable2.options[0].text).toBe('auto'); + expect(variable2.options[0].value).toBe('$__auto_interval_variable2'); }); - it('should correctly set $__auto_interval_variableX', function() { + it('should correctly set $__auto_interval_variableX', () => { var variable1Set, variable2Set, legacySet, unknownSet = false; // updateAutoValue() gets called repeatedly: once directly once via VariableSrv.validateVariableSelectionState() // So check that all calls are valid rather than expect a specific number and/or ordering of calls - for (var i = 0; i < ctx.templateSrv.setGrafanaVariable.callCount; i++) { - var call = ctx.templateSrv.setGrafanaVariable.getCall(i); - switch (call.args[0]) { + for (var i = 0; i < ctx.templateSrv.setGrafanaVariable.mock.calls.length; i++) { + var call = ctx.templateSrv.setGrafanaVariable.mock.calls[i]; + switch (call[0]) { case '$__auto_interval_variable1': - expect(call.args[1]).to.be('12h'); + expect(call[1]).toBe('12h'); variable1Set = true; break; case '$__auto_interval_variable2': - expect(call.args[1]).to.be('10m'); + expect(call[1]).toBe('10m'); variable2Set = true; break; case '$__auto_interval': - expect(call.args[1]).to.match(/^(12h|10m)$/); + expect(call[1]).toEqual(expect.stringMatching(/^(12h|10m)$/)); legacySet = true; break; default: @@ -559,10 +576,25 @@ describe('VariableSrv', function() { break; } } - expect(variable1Set).to.be.equal(true); - expect(variable2Set).to.be.equal(true); - expect(legacySet).to.be.equal(true); - expect(unknownSet).to.be.equal(false); + expect(variable1Set).toEqual(true); + expect(variable2Set).toEqual(true); + expect(legacySet).toEqual(true); + expect(unknownSet).toEqual(false); }); }); }); + +function getVarMockConstructor(variable, model, ctx) { + switch (model.model.type) { + case 'datasource': + return new variable(model.model, ctx.datasourceSrv, ctx.variableSrv, ctx.templateSrv); + case 'query': + return new variable(model.model, ctx.datasourceSrv, ctx.templateSrv, ctx.variableSrv); + case 'interval': + return new variable(model.model, ctx.timeSrv, ctx.templateSrv, ctx.variableSrv); + case 'custom': + return new variable(model.model, ctx.variableSrv); + default: + return new variable(model.model); + } +} diff --git a/public/app/features/templating/specs/variable_srv_init_specs.ts b/public/app/features/templating/specs/variable_srv_init.test.ts similarity index 56% rename from public/app/features/templating/specs/variable_srv_init_specs.ts rename to public/app/features/templating/specs/variable_srv_init.test.ts index 11639c6aa8f..ea8689f528b 100644 --- a/public/app/features/templating/specs/variable_srv_init_specs.ts +++ b/public/app/features/templating/specs/variable_srv_init.test.ts @@ -1,36 +1,31 @@ -import { describe, beforeEach, it, sinon, expect, angularMocks } from 'test/lib/common'; - import '../all'; import _ from 'lodash'; -import helpers from 'test/specs/helpers'; -import { Emitter } from 'app/core/core'; +import { VariableSrv } from '../variable_srv'; +import $q from 'q'; describe('VariableSrv init', function() { - var ctx = new helpers.ControllerTestContext(); + let templateSrv = { + init: vars => { + this.variables = vars; + }, + variableInitialized: () => {}, + updateTemplateData: () => {}, + replace: str => + str.replace(this.regex, match => { + return match; + }), + }; - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.controllers')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach( - angularMocks.module(function($compileProvider) { - $compileProvider.preAssignBindingsEnabled(true); - }) - ); + let $injector = {}; + let $rootscope = { + $on: () => {}, + }; - beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location'])); - beforeEach( - angularMocks.inject(($rootScope, $q, $location, $injector) => { - ctx.$q = $q; - ctx.$rootScope = $rootScope; - ctx.$location = $location; - ctx.variableSrv = $injector.get('variableSrv'); - ctx.$rootScope.$digest(); - }) - ); + let ctx = {}; function describeInitScenario(desc, fn) { - describe(desc, function() { + describe(desc, () => { var scenario: any = { urlParams: {}, setup: setupFn => { @@ -38,22 +33,34 @@ describe('VariableSrv init', function() { }, }; - beforeEach(function() { + beforeEach(async () => { scenario.setupFn(); - ctx.datasource = {}; - ctx.datasource.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult)); - - ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ctx.datasource)); - ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources); - - ctx.$location.search = sinon.stub().returns(scenario.urlParams); - ctx.dashboard = { - templating: { list: scenario.variables }, - events: new Emitter(), + ctx = { + datasource: { + metricFindQuery: jest.fn(() => Promise.resolve(scenario.queryResult)), + }, + datasourceSrv: { + get: () => Promise.resolve(ctx.datasource), + getMetricSources: () => scenario.metricSources, + }, + templateSrv, }; - ctx.variableSrv.init(ctx.dashboard); - ctx.$rootScope.$digest(); + ctx.variableSrv = new VariableSrv($rootscope, $q, {}, $injector, templateSrv); + + $injector.instantiate = (variable, model) => { + return getVarMockConstructor(variable, model, ctx); + }; + + ctx.variableSrv.datasource = ctx.datasource; + ctx.variableSrv.datasourceSrv = ctx.datasourceSrv; + + ctx.variableSrv.$location.search = () => scenario.urlParams; + ctx.variableSrv.dashboard = { + templating: { list: scenario.variables }, + }; + + await ctx.variableSrv.init(ctx.variableSrv.dashboard); scenario.variables = ctx.variableSrv.variables; }); @@ -78,8 +85,8 @@ describe('VariableSrv init', function() { }); it('should update current value', () => { - expect(scenario.variables[0].current.value).to.be('new'); - expect(scenario.variables[0].current.text).to.be('new'); + expect(scenario.variables[0].current.value).toBe('new'); + expect(scenario.variables[0].current.text).toBe('new'); }); }); }); @@ -111,12 +118,12 @@ describe('VariableSrv init', function() { }); it('should update child variable', () => { - expect(scenario.variables[1].options.length).to.be(2); - expect(scenario.variables[1].current.text).to.be('google-server1'); + expect(scenario.variables[1].options.length).toBe(2); + expect(scenario.variables[1].current.text).toBe('google-server1'); }); it('should only update it once', () => { - expect(ctx.datasource.metricFindQuery.callCount).to.be(1); + expect(ctx.variableSrv.datasource.metricFindQuery).toHaveBeenCalledTimes(1); }); }); }); @@ -140,9 +147,9 @@ describe('VariableSrv init', function() { ]; }); - it('should update current value', function() { + it('should update current value', () => { var variable = ctx.variableSrv.variables[0]; - expect(variable.options.length).to.be(2); + expect(variable.options.length).toBe(2); }); }); @@ -164,19 +171,19 @@ describe('VariableSrv init', function() { scenario.urlParams['var-apps'] = ['val2', 'val1']; }); - it('should update current value', function() { + it('should update current value', () => { var variable = ctx.variableSrv.variables[0]; - expect(variable.current.value.length).to.be(2); - expect(variable.current.value[0]).to.be('val2'); - expect(variable.current.value[1]).to.be('val1'); - expect(variable.current.text).to.be('val2 + val1'); - expect(variable.options[0].selected).to.be(true); - expect(variable.options[1].selected).to.be(true); + expect(variable.current.value.length).toBe(2); + expect(variable.current.value[0]).toBe('val2'); + expect(variable.current.value[1]).toBe('val1'); + expect(variable.current.text).toBe('val2 + val1'); + expect(variable.options[0].selected).toBe(true); + expect(variable.options[1].selected).toBe(true); }); - it('should set options that are not in value to selected false', function() { + it('should set options that are not in value to selected false', () => { var variable = ctx.variableSrv.variables[0]; - expect(variable.options[2].selected).to.be(false); + expect(variable.options[2].selected).toBe(false); }); }); @@ -198,19 +205,34 @@ describe('VariableSrv init', function() { scenario.urlParams['var-apps'] = ['val2', 'val1']; }); - it('should update current value', function() { + it('should update current value', () => { var variable = ctx.variableSrv.variables[0]; - expect(variable.current.value.length).to.be(2); - expect(variable.current.value[0]).to.be('val2'); - expect(variable.current.value[1]).to.be('val1'); - expect(variable.current.text).to.be('Val2 + Val1'); - expect(variable.options[0].selected).to.be(true); - expect(variable.options[1].selected).to.be(true); + expect(variable.current.value.length).toBe(2); + expect(variable.current.value[0]).toBe('val2'); + expect(variable.current.value[1]).toBe('val1'); + expect(variable.current.text).toBe('Val2 + Val1'); + expect(variable.options[0].selected).toBe(true); + expect(variable.options[1].selected).toBe(true); }); - it('should set options that are not in value to selected false', function() { + it('should set options that are not in value to selected false', () => { var variable = ctx.variableSrv.variables[0]; - expect(variable.options[2].selected).to.be(false); + expect(variable.options[2].selected).toBe(false); }); }); }); + +function getVarMockConstructor(variable, model, ctx) { + switch (model.model.type) { + case 'datasource': + return new variable(model.model, ctx.datasourceSrv, ctx.variableSrv, ctx.templateSrv); + case 'query': + return new variable(model.model, ctx.datasourceSrv, ctx.templateSrv, ctx.variableSrv); + case 'interval': + return new variable(model.model, {}, ctx.templateSrv, ctx.variableSrv); + case 'custom': + return new variable(model.model, ctx.variableSrv); + default: + return new variable(model.model); + } +} diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index cdabe577f96..fc79d12ff9e 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -250,8 +250,14 @@ export class TemplateSrv { fillVariableValuesForUrl(params, scopedVars) { _.each(this.variables, function(variable) { if (scopedVars && scopedVars[variable.name] !== void 0) { + if (scopedVars[variable.name].skipUrlSync) { + return; + } params['var-' + variable.name] = scopedVars[variable.name].value; } else { + if (variable.skipUrlSync) { + return; + } params['var-' + variable.name] = variable.getValueForUrl(); } }); diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index 8a096dd9ad2..8ad3c2845e2 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -38,7 +38,11 @@ export class VariableSrv { }); } - onDashboardRefresh() { + onDashboardRefresh(evt, payload) { + if (payload && payload.fromVariableValueUpdated) { + return Promise.resolve({}); + } + var promises = this.variables.filter(variable => variable.refresh === 2).map(variable => { var previousOptions = variable.options.slice(); @@ -130,7 +134,7 @@ export class VariableSrv { return this.$q.all(promises).then(() => { if (emitChangeEvents) { this.$rootScope.$emit('template-variable-value-updated'); - this.$rootScope.$broadcast('refresh'); + this.$rootScope.$broadcast('refresh', { fromVariableValueUpdated: true }); } }); } diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 1919759334b..656103adce2 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -51,12 +51,16 @@ Sign in with GitHub - + -